diff --git a/.gitignore b/.gitignore index 70b43ab195..7271efded3 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,9 @@ supabase/.temp # agent plans .opencode/plans/ +# beads task workspace (per-agent local state) +.beads/ + # misc TMP_CI_commit_msg.txt *.csv diff --git a/.plans/impact-affiliate-tracking.md b/.plans/impact-affiliate-tracking.md deleted file mode 100644 index 3d27c2680f..0000000000 --- a/.plans/impact-affiliate-tracking.md +++ /dev/null @@ -1,448 +0,0 @@ -# Impact.com Affiliate Tracking Integration for KiloClaw - -## Context - -Implementing Impact.com affiliate tracking for KiloClaw subscriptions. External partners drive traffic via tracking links and earn commissions on conversions. This replaces the existing Rewardful integration entirely. - -### Events to Track (per Pierluigi's spec) - -| Event | Type | Payout | -| ----------- | ------------- | -------------------------- | -| SIGNUP | Lead (parent) | Stats-only, $0 | -| TRIAL_START | Sale (child) | Stats-only or small payout | -| TRIAL_END | Sale (child) | Stats-only | -| SALE | Sale (child) | Commissionable | - -### Integration Architecture (Hybrid) - -- **UTT (JavaScript)**: Installed on app.kilo.ai for cross-domain tracking and `identify` calls. Also needed on kilo.ai (separate codebase). -- **Server-side API**: Backend POSTs conversion events to Impact.com Conversions API on signup, trial start, trial end, and subscription payment. More reliable than client-side tracking (resistant to ad blockers/ITP). - ---- - -## Implementation Steps - -### 1. Remove Rewardful Integration - -**Files to modify:** - -- `src/lib/rewardful.ts` -- delete entirely -- `src/types/rewardful.d.ts` -- delete entirely -- `src/routers/kiloclaw-router.ts` ~line 1600-1605 -- remove `getRewardfulReferral()` call and `client_reference_id` from checkout session -- `src/app/layout.tsx` or wherever Rewardful's `rw.js` script is loaded -- remove the script tag -- `package.json` -- remove any Rewardful dependencies if present - -### 2. Database Migration: Add `user_affiliate_attributions` Table - -Rather than adding an Impact-specific column to `kilocode_users`, introduce a separate `user_affiliate_attributions` table. This decouples affiliate tracking from the user schema and allows us to onboard additional affiliate/tracking programs in the future without further migrations. - -**File:** `packages/db/src/schema-types.ts` - -Define a provider enum following the existing pattern (`const` object + derived type): - -```typescript -export const AffiliateProvider = { - Impact: 'impact', -} as const; - -export type AffiliateProvider = (typeof AffiliateProvider)[keyof typeof AffiliateProvider]; -``` - -New providers are added here as additional entries. - -**File:** `packages/db/src/schema.ts` - -```typescript -export const user_affiliate_attributions = pgTable( - 'user_affiliate_attributions', - { - id: uuid().primaryKey().defaultRandom(), - user_id: text() - .notNull() - .references(() => kilocode_users.id), - provider: text().notNull().$type(), - tracking_id: text().notNull(), // provider-specific identifier (e.g. im_ref value for Impact) - created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), - }, - table => [ - // One attribution per provider per user (first-touch wins) - unique('UQ_user_affiliate_attributions_user_provider').on(table.user_id, table.provider), - index('IDX_user_affiliate_attributions_user_id').on(table.user_id), - enumCheck('user_affiliate_attributions_provider_check', table.provider, AffiliateProvider), - ] -); -``` - -Then run `pnpm drizzle generate` to create the migration. - -**Design notes:** - -- The `AffiliateProvider` enum is enforced at both the TypeScript level (`$type<>`) and the database level (`enumCheck`). -- The unique constraint on `(user_id, provider)` enforces first-touch attribution per provider. To record a tracking ID, use an upsert that no-ops on conflict. -- Querying a user's Impact tracking ID: `WHERE user_id = ? AND provider = 'impact'`. -- Adding a new provider later only requires adding a value to the `AffiliateProvider` enum and regenerating the migration. - -**GDPR note:** The tracking ID is an opaque identifier, not PII. However, since it's associated with a user, update `softDeleteUser` in `src/lib/user.ts` to delete rows from this table on user deletion, and add a corresponding test. - -### 3. Environment Variables - -Add the following env vars (values will be provided by Impact.com after contract signing): - -```env -# Impact.com API credentials -IMPACT_ACCOUNT_SID= # Account SID from Impact.com dashboard -IMPACT_AUTH_TOKEN= # Auth Token from Impact.com dashboard - -# Impact.com Program/Campaign IDs -IMPACT_CAMPAIGN_ID= # Campaign/Program ID - -# Impact.com Event Type IDs (configured in Impact.com dashboard) -IMPACT_SIGNUP_EVENT_TYPE_ID= # Lead event for user signup -IMPACT_TRIAL_START_EVENT_TYPE_ID= # Sale event for trial start -IMPACT_TRIAL_END_EVENT_TYPE_ID= # Sale event for trial end -IMPACT_SALE_EVENT_TYPE_ID= # Sale event for KiloClaw payment (initial + renewals) - -# Impact.com UTT identifier (for frontend) -NEXT_PUBLIC_IMPACT_UTT_ID= # UUID for the UTT script URL (e.g. XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXX) -``` - -**File:** Add these to `src/env.ts` (or equivalent env validation schema) -- make them optional so the app doesn't crash in environments where Impact isn't configured. Server-side vars use `z.string().optional()`, and the UTT ID uses `NEXT_PUBLIC_` prefix for client access. - -### 4. Impact.com API Client (`src/lib/impact.ts`) - -Create a server-side API client for Impact.com's Conversions API. - -**Key functions:** - -```typescript -// SHA-1 hash email for Impact.com (they apply additional HMAC on their end) -function hashEmailForImpact(email: string): string; - -// Send a Lead conversion (SIGNUP) -async function trackSignupConversion(params: { - clickId: string | null; - customerId: string; // our user ID - customerEmail: string; // raw email (will be hashed) - eventDate: Date; -}): Promise; - -// Send a Sale conversion (TRIAL_START, TRIAL_END, SALE) -async function trackSaleConversion(params: { - eventTypeId: string; - clickId: string | null; - customerId: string; - customerEmail: string; - orderId: string; // Stripe invoice ID or subscription ID - amount: number; // in USD (decimal) - currencyCode: string; - eventDate: Date; - itemCategory: string; // e.g. "kiloclaw-standard", "kiloclaw-trial" - itemName: string; // e.g. "KiloClaw Standard Plan" - promoCode?: string; -}): Promise; -``` - -**API call format** (from Impact docs): - -``` -POST https://api.impact.com/Advertisers/{AccountSID}/Conversions -Authorization: Basic base64(AccountSID:AuthToken) -Content-Type: application/x-www-form-urlencoded - -CampaignId=...&EventTypeId=...&ClickId=...&CustomerId=...&CustomerEmail=...&OrderId=...&ItemSubTotal1=...&ItemCategory1=...&ItemName1=...&ItemQuantity1=1&CurrencyCode=USD&EventDate=... -``` - -**Error handling:** - -- Log failures but don't block the main flow (fire-and-forget with retry) -- Retry on 5xx responses (Impact.com recommends this) -- Gate all calls behind `IMPACT_ACCOUNT_SID` being set (no-op when not configured) - -### 5. Frontend: Install UTT Script - -**File:** `src/app/layout.tsx` (or the root layout) - -Add the UTT script to the ``: - -```html - -``` - -Gate behind `NEXT_PUBLIC_IMPACT_UTT_ID` being set. The UTT ID is environment-specific (different for test vs production). - -**Cross-domain note:** UTT installed on both kilo.ai (marketing site, separate codebase) and app.kilo.ai (this codebase) handles cross-domain tracking automatically. Impact.com configures the domains in their dashboard. - -### 6. Frontend: `identify` Call on Authentication - -After a user logs in or signs up, call the UTT `identify` function to bridge the user's identity for cross-device attribution. - -**File:** Create a client component `src/components/ImpactIdentify.tsx` (or add to an existing authenticated layout wrapper) - -```typescript -// Called once user is authenticated -ire('identify', { - customerId: userId, - customerEmail: sha1(userEmail), // SHA-1 hashed - customProfileId: '', // UUID cookie if we generate one, or empty string -}); -``` - -This should fire on every authenticated page load (in the root authenticated layout). Use the `ire` global injected by the UTT script. - -**Type definition:** Add `src/types/impact.d.ts`: - -```typescript -declare global { - interface Window { - ire?: (...args: unknown[]) => void; - } - function ire(...args: unknown[]): void; -} -``` - -### 7. Click ID Capture: Preserve `im_ref` Through Auth Flow - -When a user arrives at app.kilo.ai with `?im_ref=...` (passed from kilo.ai or directly from an affiliate link), we need to persist it through the OAuth flow and store it in the `user_affiliate_attributions` table. - -**Step 7a: Preserve through OAuth callback** - -**File:** `src/lib/getSignInCallbackUrl.ts` - -Add `im_ref` to the list of preserved query parameters (alongside existing `source` and `callbackPath`): - -```typescript -const imRef = searchParams.get('im_ref'); -if (imRef) url.searchParams.set('im_ref', imRef); -``` - -**Step 7b: Read im_ref after OAuth and store attribution** - -**File:** `src/app/users/after-sign-in/` (the OAuth callback handler) - -After successful authentication: - -1. Read `im_ref` from the callback URL's query params -2. Upsert a row into `user_affiliate_attributions` with `provider = 'impact'` and the click ID (no-op on conflict to preserve first-touch) - -**File:** `src/lib/user.ts` in `createOrUpdateUser()` - -Add an optional `impactClickId` parameter. After the user row is created/updated, insert the attribution: - -```typescript -if (impactClickId) { - await db - .insert(user_affiliate_attributions) - .values({ user_id: userId, provider: 'impact', tracking_id: impactClickId }) - .onConflictDoNothing(); -} -``` - -The unique constraint on `(user_id, provider)` enforces first-touch attribution automatically. - -### 8. Track SIGNUP Event (Lead) - -When a new user is created, fire a Lead conversion to Impact.com. - -**File:** `src/lib/user.ts` in `createOrUpdateUser()` - -After the user row is inserted (inside or just after the transaction), if this is a new user and they have an `impactClickId`: - -```typescript -// Fire-and-forget, don't block user creation -trackSignupConversion({ - clickId: impactClickId, - customerId: newUserId, - customerEmail: args.google_user_email, - eventDate: new Date(), -}).catch(err => console.error('Impact signup tracking failed:', err)); -``` - -**Note:** This is the "Lead" event in Impact's parent-child structure. The Impact API correlates subsequent Sale events via `CustomerId`, so we don't need to store the returned action ID. - -### 9. Track TRIAL_START Event - -When a KiloClaw trial subscription is created. - -**File:** `src/lib/kiloclaw/stripe-handlers.ts` (or wherever trial creation is handled) - -Identify the code path where a trial subscription transitions to active. Look up the user's attribution and fire: - -```typescript -const attribution = await getAffiliateAttribution(userId, 'impact'); - -trackSaleConversion({ - eventTypeId: env.IMPACT_TRIAL_START_EVENT_TYPE_ID, - clickId: attribution?.tracking_id ?? null, - customerId: user.id, - customerEmail: user.google_user_email, - orderId: stripeSubscriptionId, - amount: 0, // trial is free - currencyCode: 'usd', - eventDate: new Date(), - itemCategory: 'kiloclaw-trial', - itemName: 'KiloClaw Trial', -}); -``` - -### 10. Track TRIAL_END Event - -When a KiloClaw trial subscription ends (either by converting to paid or expiring). - -**File:** `src/lib/kiloclaw/stripe-handlers.ts` - -In the subscription status change handler, when trial → active or trial → canceled: - -```typescript -trackSaleConversion({ - eventTypeId: env.IMPACT_TRIAL_END_EVENT_TYPE_ID, - clickId: attribution?.tracking_id ?? null, - customerId: user.id, - customerEmail: user.google_user_email, - orderId: stripeSubscriptionId, - amount: 0, - currencyCode: 'usd', - eventDate: new Date(), - itemCategory: 'kiloclaw-trial-end', - itemName: 'KiloClaw Trial End', -}); -``` - -### 11. Track SALE Event (Commissionable) - -When a KiloClaw subscription invoice is paid (initial purchase or renewal). This is the primary commissionable event. - -**File:** `src/lib/kiloclaw/stripe-handlers.ts` in `handleKiloClawInvoicePaid()` - -After successful invoice settlement, look up the attribution and fire the conversion: - -```typescript -const attribution = await getAffiliateAttribution(userId, 'impact'); - -trackSaleConversion({ - eventTypeId: env.IMPACT_SALE_EVENT_TYPE_ID, - clickId: attribution?.tracking_id ?? null, - customerId: userId, - customerEmail: user?.google_user_email ?? '', - orderId: invoiceId, // Stripe invoice ID as unique order identifier - amount: amountPaidUsd, // invoice.amount_paid converted to dollars - currencyCode: invoice.currency ?? 'usd', - eventDate: new Date(), - itemCategory: `kiloclaw-${plan}`, // e.g. "kiloclaw-standard", "kiloclaw-commit" - itemName: `KiloClaw ${plan} Plan`, - promoCode: invoice.discount?.coupon?.name, -}); -``` - -**Important:** This should fire for every `invoice.paid` event, not just the first one. Impact.com handles recurring commission logic internally based on their contract configuration, so they need to see each subscription payment. - -### 12. Stripe Checkout Metadata Update - -Pass the Impact click ID through Stripe checkout so it's available in webhook handlers even if the attribution table lookup fails. - -**File:** `src/routers/kiloclaw-router.ts` in `createSubscriptionCheckout` - -Look up the attribution and include the click ID in checkout metadata: - -```typescript -const attribution = await getAffiliateAttribution(ctx.user.id, 'impact'); - -// In session creation: -subscription_data: { - metadata: { - type: 'kiloclaw', - plan: input.plan, - kiloUserId: ctx.user.id, - impactClickId: attribution?.tracking_id ?? '', - }, -}, -``` - ---- - -## File Summary - -| Action | File | Description | -| -------- | ------------------------------------- | ------------------------------------------------- | -| Delete | `src/lib/rewardful.ts` | Remove Rewardful integration | -| Delete | `src/types/rewardful.d.ts` | Remove Rewardful types | -| Edit | `src/routers/kiloclaw-router.ts` | Remove Rewardful, add Impact metadata to checkout | -| Edit | `src/app/layout.tsx` | Remove Rewardful script, add UTT script | -| Edit | `packages/db/src/schema.ts` | Add `user_affiliate_attributions` table | -| Generate | `packages/db/src/migrations/` | `pnpm drizzle generate` | -| Create | `src/lib/impact.ts` | Impact.com API client | -| Create | `src/types/impact.d.ts` | TypeScript declarations for `ire()` | -| Create | `src/components/ImpactIdentify.tsx` | Client component for identify call | -| Edit | `src/lib/getSignInCallbackUrl.ts` | Preserve `im_ref` through OAuth | -| Edit | `src/lib/user.ts` | Store attribution, track signup, GDPR cleanup | -| Edit | `src/lib/user.test.ts` | Add GDPR test for `user_affiliate_attributions` | -| Edit | `src/lib/kiloclaw/stripe-handlers.ts` | Track trial + subscription events | -| Edit | `src/env.ts` | Add Impact env var validation | - ---- - -## Testing Plan - -1. **Unit tests**: Test `hashEmailForImpact()`, test Impact API client with mocked HTTP -2. **GDPR test**: Verify `softDeleteUser` deletes `user_affiliate_attributions` rows -3. **Integration test**: End-to-end flow with test Impact.com account (provided during onboarding) -4. **Manual E2E test** (per Impact.com's testing requirements): - - Create a test partner account in Impact dashboard - - Click a test tracking link → verify `im_ref` captured - - Sign up → verify Lead conversion appears in Impact - - Start trial → verify Trial Start event - - Pay subscription → verify Sale conversion with correct amount - - Check Impact dashboard for attribution - ---- - -## Open Items (Require Input From Impact.com) - -These depend on values from your Impact.com account which aren't available until after contract + account setup: - -1. **Account SID + Auth Token** -- from Impact.com dashboard Settings > API -2. **Campaign ID** -- from Impact.com after program creation -3. **Event Type IDs** -- for each event (SIGNUP, TRIAL_START, TRIAL_END, SALE). Impact's Implementation Engineer configures these. -4. **UTT Script ID** -- the UUID in the UTT script URL, from Settings > General > Tracking -5. **Cross-domain configuration** -- Impact configures kilo.ai + app.kilo.ai in their dashboard - ---- - -## Sequencing - -The implementation can be split into these PRs: - -**PR 1: Database + API Client + Remove Rewardful** - -- Schema migration (add `user_affiliate_attributions` table) -- Create `src/lib/impact.ts` API client -- Remove Rewardful code -- Add env vars to validation schema -- GDPR update - -**PR 2: Click ID Capture + Frontend UTT** - -- Install UTT script -- Preserve `im_ref` through auth flow -- Store affiliate attribution on user creation -- `identify` call component - -**PR 3: Server-side Conversion Tracking** - -- Signup Lead event -- Trial start/end events -- Sale event (initial + renewals) -- Checkout metadata update diff --git a/.plans/impact-tracking-attribution.md b/.plans/impact-tracking-attribution.md deleted file mode 100644 index 9d913d76ef..0000000000 --- a/.plans/impact-tracking-attribution.md +++ /dev/null @@ -1,126 +0,0 @@ -# Affiliate Event Ledger With Cron-Only Dispatch - -## Summary - -- Use a lean `user_affiliate_events` table as the durable ledger/outbox for affiliate conversions. -- Make the web-app cron route the only dispatcher to Impact. Auth, checkout, Stripe webhook, and billing paths only enqueue rows. -- Enforce parent-before-child delivery by requiring a delivered parent event before `trial_start`, `trial_end`, or `sale` can move out of `blocked`. -- Use generic internal naming: `trackingId`, `affiliateTrackingId`, and `findOrCreateParentEvent`. Only the final Impact API adapter uses `ClickId`. - -## Spec Updates - -Update `.specs/impact-affiliate-tracking.md`: - -- Replace `SIGNUP only for new user creation` with `SIGNUP once per user/provider on first attributed association.` -- Add an invariant that child events must not be sent before the parent `SIGNUP` event has been successfully delivered. - -## Schema And Naming - -- Add `user_affiliate_events` with only these fields: - - `id` - - `user_id` - - `provider` - - `event_type` - - `dedupe_key` - - `parent_event_id` nullable - - `delivery_state` - - `payload_json` - - `attempt_count` - - `next_retry_at` nullable - - `claimed_at` nullable - - `created_at` -- Use `delivery_state` values: `queued | blocked | sending | delivered | failed`. -- Add indexes for: - - unique `dedupe_key` - - claim path on `(delivery_state, coalesce(next_retry_at, '-infinity'), created_at, id)` - - `parent_event_id` -- Do not add `attribution_id` in v1. -- Keep `payload_json` normalized around generic fields such as `trackingId`, `customerId`, `customerEmailHash`, `orderId`, `eventDate`, amount/currency/item fields where relevant. The dispatcher maps this to the provider API payload. -- Rename internal helper `findOrCreateSignupParentEvent` to `findOrCreateParentEvent`. -- Resolve the parent event type from provider config in code. For Impact in v1, the parent event type is `signup`. -- Rename internal `clickId` parameters and fields to `trackingId`. -- Rename Stripe checkout metadata from `impactClickId` to `affiliateTrackingId`. -- Read Stripe metadata compatibly during rollout: prefer `affiliateTrackingId`, fall back to legacy `impactClickId` for existing subscriptions and webhook events. -- Keep external Impact-specific names unchanged where required: - - query param remains `im_ref` - - cookie contract remains unchanged for compatibility unless a later coordinated change updates `kilo.ai` - - outbound Impact payload still uses `ClickId` - -## Dispatch And Logging - -- Enqueue rules: - - New user with first Impact attribution: create attribution row, then `findOrCreateParentEvent`, then enqueue only the parent event. - - Existing user who first gains Impact attribution on login: same flow. - - `trial_start`, `trial_end`, and `sale` enqueue child rows only after ensuring the parent row exists. - - If the parent row is not yet `delivered`, create the child row as `blocked`. -- Cron route: - - Add `/api/cron/dispatch-affiliate-events` on `* * * * *`. - - Each run claims up to 100 eligible `queued` rows with `FOR UPDATE SKIP LOCKED`, sets `delivery_state = 'sending'`, and stamps `claimed_at`. - - On success, mark row `delivered`. - - On `5xx` or network error, increment `attempt_count`, set `delivery_state = 'queued'`, clear `claimed_at`, and compute `next_retry_at`. - - On `4xx`, increment `attempt_count`, mark row `failed`, and clear `claimed_at`. - - Before claiming new work, return stale `sending` rows with old `claimed_at` back to `queued`. - - After a parent row becomes `delivered`, promote its `blocked` children to `queued`. -- Replace direct Impact dispatch in: - - auth user creation - - after-sign-in attribution recovery - - KiloClaw trial start - - Stripe-driven KiloClaw trial end - - Stripe `invoice.paid` sale tracking - - billing-worker trial-expiry side effect -- Replace the billing side-effect action with a generic enqueue action rather than direct tracking. -- Structured logs: - - Use a dedicated logger source such as `affiliate-events`. - - Every enqueue, claim, dispatch success, retry, unblock, and permanent failure log must include: - - `affiliate_event_id` - - `affiliate_parent_event_id` - - `affiliate_provider` - - `affiliate_event_type` - - `affiliate_dedupe_key` - - `user_id` - - `delivery_state` - - `attempt_count` - - Dispatch logs should also include: - - `dispatch_source` (`cron`) - - `action_tracker_id` when provider is Impact - - `order_id` when present - - `tracking_id_present` - - Failure logs should include: - - `failure_kind` (`http_4xx`, `http_5xx`, `network`) - - `status_code` when available - - The DB row id is the primary join key between logs and the event ledger. - -## Test Plan - -- Schema/service tests: - - dedupe keys prevent duplicate parent and child rows - - blocked children do not dispatch before the parent is delivered - - delivered parents promote blocked children to queued - - stale `sending` rows are reclaimed from `claimed_at` - - retries respect `next_retry_at` -- Naming/compat tests: - - internal enqueue payloads use `trackingId` - - Stripe checkout writes `affiliateTrackingId` - - webhook readers accept both `affiliateTrackingId` and legacy `impactClickId` -- Auth-flow tests: - - new attributed users enqueue exactly one parent event - - existing users gaining attribution enqueue exactly one parent event - - repeat attributed logins do not duplicate the parent event -- KiloClaw tests: - - `trial_start`, `trial_end`, and `sale` enqueue rows instead of calling Impact directly - - non-attributed users do not enqueue affiliate events - - attributed users enqueue child rows linked to the correct parent row -- Cron-route tests: - - unauthorized requests are rejected - - success marks rows delivered - - `5xx` requeues with backoff - - `4xx` marks rows failed - - success/failure logs include `affiliate_event_id` and `affiliate_dedupe_key` -- Keep and extend `impact.test.ts` so the final Impact adapter still emits `ClickId` correctly from internal `trackingId`. - -## Assumptions - -- `signup` now means the provider-specific parent event for the user's first attributed association, not only account creation. -- Logs, not extra DB columns, are the source of truth for per-attempt delivery history. -- `created_at` plus the structured logs are sufficient audit history for v1; `delivered_at`, `last_attempt_at`, `last_status_code`, `last_error`, `sending_started_at`, and generic extra timestamps are intentionally omitted. -- `softDeleteUser` must delete `user_affiliate_events` rows in addition to `user_affiliate_attributions`. diff --git a/.specs/impact-affiliate-tracking.md b/.specs/kiloclaw-affiliates.md similarity index 50% rename from .specs/impact-affiliate-tracking.md rename to .specs/kiloclaw-affiliates.md index b805793613..ef05a392af 100644 --- a/.specs/impact-affiliate-tracking.md +++ b/.specs/kiloclaw-affiliates.md @@ -2,11 +2,10 @@ ## Role of This Document -This spec defines the business rules and invariants for affiliate conversion tracking via Impact.com for KiloClaw -subscriptions. It is the source of truth for _what_ the system must guarantee — which events are tracked, how -attribution is captured, what data is sent to Impact.com, and how the system behaves when tracking infrastructure is -unavailable. It deliberately does not prescribe _how_ to implement those guarantees: handler names, column layouts, -retry strategies, and other implementation choices belong in plan documents and code, not here. +This spec defines business rules and invariants for Impact.com affiliate conversion tracking for KiloClaw +subscriptions. It is the source of truth for what the system must guarantee: tracked events, attribution capture, data +sent to Impact.com, and behavior when tracking infrastructure is unavailable. It does not prescribe implementation: +handler names, column layouts, retry strategies, and other implementation choices belong in plans and code. ## Status @@ -19,50 +18,53 @@ Updated 2026-04-17 -- define dispute-triggered sale reversals. ## Conventions -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", -"NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] -[RFC 8174] when, and only when, they appear in all capitals, as shown here. +BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capitals: "MUST", "MUST NOT", +"REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and +"OPTIONAL". ## Definitions -- **Impact.com**: The third-party affiliate tracking platform used to attribute conversions to affiliate partners. -- **UTT (Universal Tracking Tag)**: A JavaScript snippet provided by Impact.com that enables client-side tracking and - cross-domain identity bridging. -- **Click ID**: An opaque tracking identifier (`im_ref` query parameter) appended to landing page URLs by Impact.com +- **Impact.com**: Third-party affiliate tracking platform that attributes conversions to affiliate partners. +- **UTT (Universal Tracking Tag)**: Impact.com JavaScript snippet for client-side tracking and cross-domain identity + bridging. +- **Click ID**: Opaque tracking identifier (`im_ref` query parameter) that Impact.com appends to landing page URLs when a visitor arrives via an affiliate tracking link. -- **Conversion**: An event reported to Impact.com's Conversions API representing a meaningful step in the customer - lifecycle (visit, signup, trial, or subscription payment). -- **Lead event**: A conversion representing a visit or user signup. In Impact.com's parent-child model, the SIGNUP - event is the parent action. -- **Sale event**: A conversion representing a trial or subscription payment. In Impact.com's parent-child model, these - are child actions linked to the lead via the customer identifier. -- **Affiliate attribution**: A record associating a user with the affiliate tracking identifier that brought them to - the platform. -- **First-touch attribution**: The attribution model used: only the first affiliate interaction per provider is recorded - for a given user. -- **Affiliate provider**: A named affiliate tracking platform (e.g. `impact`). The system supports multiple providers, +- **Conversion**: Event reported to Impact.com's Conversions API for a meaningful customer lifecycle step: visit, + signup, trial, or subscription payment. +- **Lead event**: Conversion representing a visit or user signup. In Impact.com's parent-child model, SIGNUP is the + parent action. +- **Sale event**: Conversion representing a trial or subscription payment. In Impact.com's parent-child model, these + child actions link to the lead via the customer identifier. +- **Affiliate attribution**: Record associating a user with the affiliate tracking identifier that brought them to the + platform. +- **First-touch attribution**: Attribution model where only the first affiliate interaction per provider is recorded for + a user. +- **Affiliate provider**: Named affiliate tracking platform (e.g. `impact`). The system supports multiple providers, each storing one attribution per user. ## Overview -Affiliate tracking enables Impact.com to attribute KiloClaw conversions to the affiliate partners that referred them. -When a visitor arrives via an affiliate tracking link, the system captures and persists the tracking identifier. As the -visitor progresses through the customer lifecycle — signup, trial, subscription — the system reports each stage to -Impact.com as a conversion event, including the tracking identifier and customer details needed for attribution. +Affiliate tracking lets Impact.com attribute KiloClaw conversions to referring partners. When a visitor arrives via an +affiliate tracking link, the system captures and persists the tracking identifier. As the visitor progresses through the +customer lifecycle -- signup, trial, subscription -- the system reports each stage to Impact.com as a conversion event, +including the tracking identifier and customer details needed for attribution. -The system uses a hybrid tracking architecture: a client-side JavaScript tag (UTT) for cross-domain identity bridging, -and server-side API calls for reliable conversion reporting that is resistant to ad blockers and browser tracking -prevention. +Architecture is hybrid: client-side UTT for cross-domain identity bridging, plus server-side API calls for reliable +conversion reporting resistant to ad blockers and browser tracking prevention. This integration applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances are not eligible for affiliate tracking. +For KiloClaw conversions also governed by `.specs/kiloclaw-referrals.md`, that referral spec's conversion-time +referral-priority rules override this document's default first-touch affiliate behavior for the initial paid conversion +decision. This document remains authoritative for Impact Performance event shapes, delivery sequencing, and affiliate +renewal reporting after the winning attribution is established. + ## Rules ### Affiliate Attribution -1. The system MUST support multiple affiliate providers, identified by a provider enum. The initial provider is - `impact`. +1. The system MUST support multiple affiliate providers identified by a provider enum. The initial provider is `impact`. 2. The system MUST store at most one attribution per user per provider. @@ -70,20 +72,20 @@ eligible for affiliate tracking. persist the identifier before or during user creation. 4. The system MUST preserve the tracking identifier across the authentication flow (e.g. through OAuth redirects) so it - is available after the user is authenticated. + is available after authentication. -5. Attribution MUST use first-touch semantics: if a user already has an attribution record for a given provider, - subsequent tracking identifiers for that provider MUST NOT overwrite it. +5. Attribution MUST use first-touch semantics: if a user already has an attribution record for a provider, subsequent + tracking identifiers for that provider MUST NOT overwrite it. -6. The tracking identifier MUST be treated as opaque. The system MUST NOT parse, validate the format of, or assign - meaning to its contents. +6. The tracking identifier MUST be opaque. The system MUST NOT parse it, validate its format, or assign meaning to its + contents. -7. When a user record is deleted (e.g. GDPR soft-delete), the system MUST delete all affiliated attribution records for +7. When a user record is deleted (e.g. GDPR soft-delete), the system MUST delete all affiliate attribution records for that user. ### Conversion Events -8. The system MUST report the following conversion events to Impact.com, in order of the customer lifecycle: +8. The system MUST report these conversion events to Impact.com, in customer lifecycle order: | Event | ActionTrackerId | Impact.com Type | Trigger | | ----------- | --------------- | --------------- | --------------------------------------------- | @@ -94,51 +96,51 @@ eligible for affiliate tracking. | SALE | 71659 | Sale | Monetized KiloClaw payment period is funded | 9. Each conversion event sent to Impact.com MUST include: - - An event timestamp - - An order identifier - - The user's affiliate tracking identifier, when available for that event - - A stable customer identifier, when available for that event - - The customer's email address, SHA-1 hashed, when available for that event + - Event timestamp + - Order identifier + - User affiliate tracking identifier, when available + - Stable customer identifier, when available + - Customer email address, SHA-1 hashed, when available -10. VISIT events MUST only include `EventDate`, `ClickId`, and `OrderId`. VISIT events MUST NOT include `CustomerId`, +10. VISIT events MUST include only `EventDate`, `ClickId`, and `OrderId`. VISIT events MUST NOT include `CustomerId`, `CustomerEmail`, `IpAddress`, or `CustomerStatus`. 11. VISIT events MUST fire on the marketing site (`kilo.ai`) before a user account exists. VISIT events MUST NOT create a `user_affiliate_attributions` row. -12. When a meaningful internal order identifier is not available, the system MUST send `IR_AN_64_TS` as `OrderId`. +12. When no meaningful internal order identifier is available, the system MUST send `IR_AN_64_TS` as `OrderId`. Impact.com generates a unique alphanumeric order identifier from this macro. This applies to VISIT, SIGNUP, - TRIAL_START, and TRIAL_END events. These generated identifiers MUST NOT be relied on for internal reconciliation. + TRIAL_START, and TRIAL_END events. These generated identifiers MUST NOT be used for internal reconciliation. 13. SIGNUP and TRIAL_START events MUST include `ClickId` alongside `CustomerId` as an attribution fallback. This covers - the case where a child event is processed before the parent SIGNUP event finishes processing. For later sale events, - including `ClickId` is RECOMMENDED but not REQUIRED. + child events processed before the parent SIGNUP event finishes processing. For later sale events, including + `ClickId` is RECOMMENDED but not REQUIRED. 14. VISIT events MUST NOT include `CustomerId` because the user does not yet exist. 15. SALE events MUST include the monetized amount and currency for the funded KiloClaw period. -16. SALE events MUST include the subscription plan identifier (e.g. `kiloclaw-standard`, - `kiloclaw-commit`) as the item category. +16. SALE events MUST include the subscription plan identifier (e.g. `kiloclaw-standard`, `kiloclaw-commit`) as the item + category. -17. SALE events MUST be reported for every monetized KiloClaw payment period (both initial and renewal), including - Stripe invoice settlements and pure-credit deductions. +17. SALE events MUST be reported for every monetized KiloClaw payment period (initial and renewal), including Stripe + invoice settlements and pure-credit deductions. 18. Conversion events SHOULD include a promo code when one was applied to the transaction. 19. The SIGNUP event MUST be sent at most once per user per provider, on that user's first attributed association for - the provider. This MAY occur during new user creation or during a later sign-in when an existing user first gains - affiliate attribution. + the provider. This MAY occur during new user creation or a later sign-in when an existing user first gains affiliate + attribution. 20. Child conversion events (TRIAL_START, TRIAL_END, SALE) MUST NOT be sent before the parent SIGNUP event has been successfully delivered. For Impact.com, child conversion events MUST NOT be dispatched until at least 5 minutes - after the SIGNUP event has been delivered. + after SIGNUP delivery. 21. Admin-only subscription interventions (for example admin trial resets, admin cancellations, or manual trial-date edits) MUST NOT emit affiliate conversion events. These are internal overrides, not customer lifecycle events. -22. When a Stripe-backed personal KiloClaw SALE later receives a `charge.dispute.created` event, the system MUST - submit a reversal for the full associated Impact.com commission. +22. When a Stripe-backed personal KiloClaw SALE later receives a `charge.dispute.created` event, the system MUST submit + a reversal for the full associated Impact.com commission. 23. Partial Stripe disputes MUST still reverse the full associated Impact.com commission. @@ -172,13 +174,13 @@ eligible for affiliate tracking. ### Rewardful Removal -29. The existing Rewardful integration MUST be fully removed. This includes the client-side script, server-side cookie - reading, and any checkout session metadata populated by Rewardful. +29. The existing Rewardful integration MUST be fully removed, including the client-side script, server-side cookie + reading, and checkout session metadata populated by Rewardful. ### Checkout Metadata 30. The KiloClaw checkout session MUST include the user's affiliate tracking identifier (if any) in Stripe subscription - metadata, so it is available to webhook handlers independently of a database lookup. + metadata, so webhook handlers can access it without a database lookup. ### API Contract @@ -188,7 +190,7 @@ eligible for affiliate tracking. ### Reference Values -33. The implementation MUST treat the following program identifiers as configuration constants for this integration: +33. The implementation MUST treat these program identifiers as configuration constants for this integration: - CampaignId: `50754` - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` - ActionTrackerIds: `71655`, `71656`, `71658`, `71659`, `71668` @@ -202,10 +204,9 @@ eligible for affiliate tracking. 3. When a conversion API call fails for any reason, the primary operation (user creation, invoice settlement, etc.) MUST NOT be affected. -4. Conversion events (SIGNUP, TRIAL_START, TRIAL_END, SALE) MUST only be sent for users who have an affiliate - attribution record. Users who did not arrive via an affiliate link MUST NOT generate conversion events. When an - attribution record exists but the click ID stored in it is empty or null, the event MUST still be sent with an - empty or null click ID. +4. Conversion events (SIGNUP, TRIAL_START, TRIAL_END, SALE) MUST only be sent for users with an affiliate attribution + record. Users who did not arrive via an affiliate link MUST NOT generate conversion events. When an attribution + record exists but its stored click ID is empty or null, the event MUST still be sent with an empty or null click ID. ## Changelog @@ -213,49 +214,47 @@ eligible for affiliate tracking. ### 2026-03-31 -- Rename SUBSCRIPTION_START to SALE -Renamed the SUBSCRIPTION_START event to SALE to reflect that it covers all KiloClaw payments (initial purchase and -renewals), not just subscription creation. Clarified that SALE events fire for every paid invoice. +Renamed SUBSCRIPTION_START to SALE because it covers all KiloClaw payments (initial purchase and renewals), not just +subscription creation. Clarified that SALE events fire for every paid invoice. ### 2026-04-01 -- Align spec with revised Impact integration guide -Added the VISIT and RE_SUBSCRIPTION events, switched API terminology to `ActionTrackerId`, documented JSON request -bodies, clarified `IR_AN_64_TS` order ID usage, required `ClickId` fallback on early events, added `Numeric1` month -tracking for renewals, and recorded the concrete Campaign/UTT/ActionTracker identifiers from the latest implementation -guide. +Added VISIT and RE_SUBSCRIPTION events; switched API terminology to `ActionTrackerId`; documented JSON request bodies; +clarified `IR_AN_64_TS` order ID usage; required `ClickId` fallback on early events; added `Numeric1` month tracking +for renewals; recorded the concrete Campaign/UTT/ActionTracker identifiers from the latest implementation guide. ### 2026-04-02 -- Remove RE_SUBSCRIPTION event, use SALE for all paid invoices -The RE_SUBSCRIPTION action tracker (71660) no longer exists in Impact.com. Removed the RE_SUBSCRIPTION event and -consolidated all paid KiloClaw invoice tracking under the SALE event (71659). The `Numeric1` month number field is no -longer sent. Both initial and renewal invoices now fire the same SALE conversion. +The RE_SUBSCRIPTION action tracker (71660) no longer exists in Impact.com. Removed RE_SUBSCRIPTION and consolidated all +paid KiloClaw invoice tracking under SALE (71659). The `Numeric1` month number field is no longer sent. Initial and +renewal invoices now fire the same SALE conversion. ### 2026-04-06 -- Clarify attribution-gated conversion events -Error-handling rule 4 previously required sending conversion events for all users, even those without an affiliate -attribution record. Updated to clarify that conversion events MUST only be sent for users with an attribution record -(i.e., users who arrived via an affiliate link). Sending events for non-affiliate users inflates Impact conversion -volume with unattributable data. The click ID within the attribution record may still be empty/null — the attribution -record itself is the gate, not the click ID value. +Error-handling rule 4 previously required sending conversion events for all users, including those without an affiliate +attribution record. Updated it to require conversion events only for users with an attribution record (i.e. users who +arrived via an affiliate link). Sending events for non-affiliate users inflates Impact conversion volume with +unattributable data. The click ID within the attribution record may still be empty/null; the attribution record itself +is the gate, not the click ID value. ### 2026-04-09 -- Queue parent-child delivery by attributed association -Updated the SIGNUP rule to trigger once per user/provider on the first attributed association rather than only on new -account creation. Added an invariant that child conversion events must not be sent before the parent SIGNUP event has -been successfully delivered. +Updated the SIGNUP rule to trigger once per user/provider on the first attributed association, not only on new account +creation. Added an invariant that child conversion events must not be sent before successful parent SIGNUP delivery. ### 2026-04-09 -- Count pure-credit periods as sale events and exclude admin/org flows -Clarified that SALE covers every monetized KiloClaw payment period, including pure-credit funding in addition to Stripe -invoice settlements. Explicitly excluded organization-scoped KiloClaw instances and admin-only subscription -interventions from affiliate tracking. +Clarified that SALE covers every monetized KiloClaw payment period, including pure-credit funding and Stripe invoice +settlements. Explicitly excluded organization-scoped KiloClaw instances and admin-only subscription interventions from +affiliate tracking. ### 2026-04-09 -- Delay child dispatch after SIGNUP delivery -Added a required 5-minute gap between Impact SIGNUP delivery and dispatch of child conversion events. This gives -Impact.com time to process the parent event before TRIAL_START, TRIAL_END, or SALE requests arrive. +Added a required 5-minute gap between Impact SIGNUP delivery and child conversion event dispatch, giving Impact.com +time to process the parent event before TRIAL_START, TRIAL_END, or SALE requests arrive. ### 2026-04-17 -- Reverse disputed Stripe-backed sales Added rules requiring full SALE reversals for Stripe disputes on personal KiloClaw subscriptions. Clarified that reversals happen when `charge.dispute.created` arrives, won disputes do not auto-restore commission, and legacy sales -without stored Impact action mapping remain manual follow-up. +without stored Impact action mapping require manual follow-up. diff --git a/.specs/kiloclaw-billing.md b/.specs/kiloclaw-billing.md index 400338a073..88d98c2389 100644 --- a/.specs/kiloclaw-billing.md +++ b/.specs/kiloclaw-billing.md @@ -11,6 +11,13 @@ layouts, conflict-resolution strategies, null-safety patterns, and other implementation choices belong in plan documents and code, not here. +When `.specs/kiloclaw-referrals.md` grants a KiloClaw free-month +reward, billing fulfillment is still governed by this document's core +subscription invariants. Referral rewards delay the beneficiary's next +unpaid KiloClaw renewal boundary by one calendar month and MUST NOT +break Stripe-funded, hybrid, pure-credit, commit-plan, cancellation, +or reactivation guarantees defined here. + ## Status Draft -- generated from branch `jdp/kiloclaw-billing` on 2026-03-13. diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md new file mode 100644 index 0000000000..8a96f62482 --- /dev/null +++ b/.specs/kiloclaw-referrals.md @@ -0,0 +1,559 @@ +# KiloClaw Referral Program + +## Role of This Document + +This spec defines KiloClaw referral program business rules and invariants powered by Impact Advocate. It is the source +of truth for _what_ the system must guarantee: eligibility, referral/affiliate attribution conflict resolution, referral +conversion timing, reward granting/fulfillment, and behavior when Impact Advocate or billing integrations are +unavailable. It does not prescribe _how_ to implement those guarantees; handler names, column layouts, retry strategies, +and other implementation details belong in plans and code. + +## Status + +Draft -- created 2026-04-21. Updated 2026-05-06 -- require Impact Advocate reward redemption after local reward +application. + +## Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT +RECOMMENDED", "MAY", and "OPTIONAL" are interpreted as described in BCP 14 [RFC 2119] [RFC 8174] only when they appear +in all capitals, as shown here. + +## Definitions + +- **Impact Advocate**: Impact.com referral product used to generate share links, register referral participants, + attribute referred users, and report referral lifecycle and reward events. +- **Impact Performance Program**: Existing Impact.com affiliate/conversion program for KiloClaw, CampaignId `50754`. +- **Advocate Program**: Impact Advocate referral program for KiloClaw, ProgramId `51699`. +- **UTT (Universal Tracking Tag)**: Impact.com JavaScript snippet that enables client-side tracking, first-party cookies, + and identity bridging. +- **Advocate widget**: Impact Verified Access in-app widget `p/51699/w/referrerWidget` for logged-in users to access + referral share links and referral status. +- **Referrer**: Existing user who shares a referral link and may earn a referral reward when an eligible referee + converts. +- **Referee**: Referred user who arrives through a referral link, creates a Kilo account, and may earn a referral reward + after their first eligible paid KiloClaw conversion. +- **Referral touch**: Captured Impact Advocate attribution interaction, including `_saasquatch` and related referral + parameters or cookies. The value is opaque to Kilo. +- **Valid referral touch**: Referral touch with a non-empty `_saasquatch` value, associated with the converting user's + pre-signup session or user record, where `conversion_time < touched_at + 30 * 24 hours` using server UTC timestamps. +- **Affiliate touch**: Captured Impact affiliate interaction, including the `im_ref` click identifier. The value is + opaque to Kilo. +- **Sale-attributed affiliate touch**: Affiliate touch already used to report a SALE conversion to Impact. This protects + the initial SALE and subsequent KiloClaw renewals from referral override, so an affiliate who already earned SALE + attribution continues receiving affiliate renewal attribution under the affiliate tracking spec. +- **Attribution touch**: Referral touch or affiliate touch considered by KiloClaw conversion-time attribution + resolution. +- **Valid touch**: Attribution touch that has not expired, belongs to the converting user or their pre-signup session, + and is eligible for the conversion being evaluated. +- **Referral-priority attribution**: KiloClaw referral/affiliate conflict-resolution model: at conversion time, a valid + referral touch wins over an affiliate touch unless that affiliate touch has already been sale-attributed. +- **First paid KiloClaw conversion**: Referee's first confirmed paid personal KiloClaw subscription payment period, + whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify, nor + does a purchase of inference / credits. +- **Monetized KiloClaw payment period**: KiloClaw billing period with positive Stripe-settled value, positive hybrid + settled value, or positive credit deduction. Zero-dollar invoices, fully comped periods, and admin adjustments are not + monetized payment periods. +- **Free-month reward**: Local KiloClaw billing reward that delays the beneficiary's next KiloClaw renewal by one + calendar month. It is not a general account credit. +- **Calendar month**: Billing-period extension that preserves day-of-month semantics of the current KiloClaw billing + calendar, clamping to the last valid day of the target month when necessary. +- **Reward beneficiary**: User who may receive a free-month reward. Beneficiary roles are `referrer` and `referee`. +- **Reward state**: Durable lifecycle state for a reward. Required states are `pending`, `earned`, `applied`, + `reversed`, `expired`, `canceled`, and `review_required`. +- **Active eligible personal KiloClaw subscription**: Personal KiloClaw subscription that is active, not canceling at + period end, not suspended, and not past due. +- **Personal KiloClaw subscription**: KiloClaw subscription owned by an individual user. Organization/team-scoped + KiloClaw subscriptions are not eligible. +- **Brand-new Kilo account**: User identity with no current or historical Kilo user identity under the configured + identity key before the referral touch. Adding an auth provider to an existing user is not brand-new. +- **Reward-bearing referral configuration**: Environment configuration required to create referral touches, register + Advocate participants, report Impact conversions, grant local rewards, and apply KiloClaw billing extensions. +- **Chargeback**: Stripe dispute event for the qualifying Stripe payment. +- **Fraud-marked payment**: Qualifying payment marked fraudulent by Stripe, an internal fraud process, or an authorized + operator. +- **Support review**: Durable `review_required` reward state with the triggering reason, affected billing period, and + source payment or dispute recorded. Kilo team review is required before an already-applied reward can be canceled, + clawed back, or otherwise adjusted. +- **Impact-facing status field**: Local status retained only to compare Kilo state with Impact dashboard exports or API + reads; it cannot drive eligibility, reward granting, or billing fulfillment. + +## Overview + +The KiloClaw referral program is double-sided: when an eligible existing user refers an eligible new KiloClaw paying +subscriber, referrer and referee each earn one free KiloClaw month. A reward is earned only after the referee's first +confirmed paid personal KiloClaw subscription payment and is fulfilled by delaying the beneficiary's next KiloClaw +renewal by one calendar month. + +Impact Advocate owns referral sharing, share links, referral cookies, participant registration, and Advocate program +reporting. Impact may mirror referral priority and reward settings for reporting, but Kilo owns authoritative product +eligibility, affiliate/referral attribution conflict resolution, first-paid-conversion detection, reward grant +idempotency, reward caps, and billing fulfillment. + +Existing Impact Performance Program conversion events drive Impact Advocate conversion state. The system uses +`Sale (71659)` as the paid-conversion event for paid KiloClaw periods, including renewals. + +This program applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances, team plans, admin +interventions, and non-KiloClaw purchases are out of scope. + +## Rules + +### Program Configuration + +1. The system MUST treat these identifiers as integration configuration constants: + - Impact Account: `7138521` + - Impact Performance CampaignId: `50754` + - Impact Advocate ProgramId: `51699` + - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` + - Advocate widget ID: `p/51699/w/referrerWidget` + +2. The system MUST use existing Impact Performance conversion action tracker IDs for KiloClaw lifecycle reporting: + + | Event | ActionTrackerId | Trigger | + | ----------- | --------------- | --------------------------------------------- | + | VISIT | 71668 | Visitor lands on `kilo.ai` with `im_ref` | + | SIGNUP | 71655 | New user creation with attribution | + | TRIAL_START | 71656 | KiloClaw trial subscription becomes active | + | TRIAL_END | 71658 | KiloClaw trial subscription ends (any reason) | + | SALE | 71659 | Monetized KiloClaw payment period is funded | + +3. Impact Advocate API credentials MUST remain server-side and MUST NOT be exposed to the browser. + +4. If Impact Advocate configuration is absent, referral sharing, participant registration, and Impact reconciliation MAY + be disabled, but the application MUST continue to function normally. + +5. If reward-bearing referral configuration is absent in an environment where the referral program is enabled: + - the system MUST fail closed for reward issuance; + - the system MUST log the configuration failure; and + - the system MUST NOT silently mark rewards or Impact work as completed. + +6. Referral UTT loading is controlled by the application's public Impact UTT configuration for the active environment. + +### Advocate Experience + +7. Logged-in users MUST access referral sharing through the Impact Verified Access widget. + +8. The system MUST authenticate users to Impact Advocate using the configured Verified Access contract: the JWT header + MUST set `kid` to the Impact Account SID, the JWT payload MUST contain the top-level `user` object, and the JWT MUST + be signed with the Impact Advocate Auth Token. + +9. The Impact Advocate identity contract for Kilo is: `id = plain user email`, `accountId = plain user email`, and + `email = plain user email`. + +10. The system MUST NOT allow users to alter the identity payload used to establish Advocate identity. + +11. The system MUST register every Kilo user issued an Impact Advocate Verified Access token as a participant in the + Advocate program server-side, even when the user has no inbound referral attribution. This MUST happen no later than + the first token issuance for that user. Registration MUST be idempotent across repeat issuances and MUST persist the + SaaSquatch-issued referral code per rule 51, so the user becomes resolvable as the referrer when their referees + later convert. + +### Client-Side Tracking and Identity + +12. The system MUST load the Impact UTT script on pages used by the referral program when the UTT identifier is + configured and MUST NOT load it otherwise. + +13. The system MUST invoke Impact `identify` on pages used by the referral program. + +14. Anonymous `identify` calls MUST pass empty string values for unknown `customerId` and `customerEmail`. The system + MUST NOT pass `undefined`, `null`, placeholders, or fake identifiers for unknown users. + +15. Logged-in `identify` calls MUST pass a stable customer identifier and SHA-1 hashed email. + +16. `identify` calls MUST include a stable `customProfileId` derived from the Kilo user ID for logged-in users and a + stable first-party anonymous ID for anonymous users. + +17. The system MUST treat `_saasquatch`, `rsCode`, `rsShareMedium`, `rsEngagementMedium`, `im_ref`, and related tracking + values as opaque. The system MUST NOT parse, validate the internal format of, or assign meaning to these values. + +18. Opaque tracking values MUST have a documented maximum accepted length, MUST be stored as UTF-8 strings, and MUST be + ignored for attribution when they exceed that maximum. Logs MUST redact or truncate opaque tracking values. + +### Referral Touch Capture + +19. When a visitor opens an Impact Advocate referral link, the system MUST recognize that referral before signup and + preserve it through account creation so the referral can be associated with the newly created user. + +20. A referral touch is valid for attribution only when it contains a non-empty `_saasquatch` value. If `_saasquatch` is + absent, the system MAY preserve related metadata for diagnostics but MUST NOT treat it as a valid referral touch. + +21. A referral touch SHOULD include related opaque metadata when available, including `rsCode`, `rsShareMedium`, + `rsEngagementMedium`, UTM parameters, and sanitized landing path. + +22. Referral touch capture MUST preserve attribution across the authentication flow, including OAuth redirects and + callback URLs. + +23. Referral touches MUST expire 30 days after the touch time. A touch is valid only when `conversion_time < touched_at + - 30 \* 24 hours`, using server UTC timestamps. A touch at or after that instant is expired. + +24. The system MUST associate pre-signup referral touches with the created user during signup or first authenticated + request after signup. + +25. Capturing or associating a referral touch MUST NOT grant a reward. + +26. If a user arrives with multiple referral touches, the system MUST preserve enough chronological information to + resolve referral-priority attribution at conversion time. + +### Affiliate and Referral Attribution Priority + +27. KiloClaw referral rewards and KiloClaw affiliate attribution MUST share a 30-day conversion-time attribution window. + +28. At first paid KiloClaw conversion time, the system MUST evaluate valid affiliate and referral touches together. + +29. For KiloClaw conversions governed by this referral spec, referral-priority attribution overrides the permanent + first-touch affiliate attribution rules in `.specs/kiloclaw-affiliates.md`. + +30. A valid referral touch MUST win over a valid affiliate touch unless the affiliate touch has already been + sale-attributed before the referral touch occurred. Initial attribution for a not-yet-attributed SALE MUST prefer + the valid referral touch. + +31. A sale-attributed affiliate touch MUST keep affiliate attribution for the initial SALE and subsequent KiloClaw + renewals only when that initial SALE occurred before the referral touch. Referral touches MUST NOT retroactively + override those affiliate-attributed SALE events. + +32. If multiple valid referral touches exist and no sale-attributed affiliate touch is present, the oldest valid + referral touch MUST win. + +33. If no valid referral touch exists, the oldest valid affiliate touch MUST win. + +34. If all touches are expired or invalid, neither affiliate attribution nor referral rewards win for that conversion. + +35. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. + +36. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for + reward or payout purposes. + +37. The system MUST record when an affiliate touch has been attributed to a SALE conversion to preserve affiliate + attribution for that initial sale and subsequent KiloClaw renewals. + +38. The system MUST implement at least these attribution outcomes. + +| Scenario | Expected winner | +| ---------------------------------------------------------------------------- | --------------- | +| Affiliate first, referral second, both valid, no prior affiliate SALE | Referral | +| Affiliate first, referral second, both valid, affiliate SALE before referral | Affiliate | +| Referral first, affiliate second, both valid, no prior affiliate SALE | Referral | +| Only affiliate valid | Affiliate | +| Only referral valid | Referral | +| All touches expired or invalid | None | + +39. Attribution resolution for referral rewards MUST happen at conversion time, not only at signup time. + +40. Impact-side attribution MUST NOT override local eligibility, reward caps, or billing fulfillment decisions. + +### Referred Participant Registration + +41. When a new user signs up with `_saasquatch` attribution, the system MUST attempt to register or upsert the user as a + referred participant in Impact Advocate. + +42. Register Participant requests MUST be made server-side. + +43. Register Participant requests MUST pass the captured `_saasquatch` value as opaque cookie attribution. + +44. Register Participant requests SHOULD include locale and country code when available. + +45. If `_saasquatch` is present during signup, referral touch association and participant registration enqueueing MUST + occur before signup is considered complete, but external Impact delivery MUST NOT block user access. + +46. Register Participant failures MUST be recorded for retry or reconciliation. + +47. Transient participant registration failures MUST leave the registration in a retryable state until it succeeds, is + superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. + +48. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request + payload or configuration is corrected. + +49. Register Participant requests MUST use the user's plain email for Advocate `id` and `accountId`. + +50. Register Participant requests MUST include plain-text email only as the Advocate contact email. + +51. On a successful Register Participant response, the system MUST persist the program-scoped referral code returned in + `referralCodes[]` against the participant record so inbound referral touches can resolve the originating + Advocate user. Persistence MUST be idempotent: re-running registration for the same participant MUST NOT corrupt or + duplicate the code. If another participant already holds the same code (vanishingly unlikely under SaaSquatch's + per-tenant uniqueness guarantee, but constraint-protected on the Kilo side), the new participant's code MUST NOT be + persisted; the rest of the registration success state MUST still be recorded. + +### Referee Eligibility + +52. A referee MUST be a brand-new Kilo account to qualify for referral rewards. + +53. Existing users MUST NOT qualify as referees, even if they later click a referral link. + +54. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. + +55. Previously deleted users MUST NOT qualify as referees. Disqualification MUST use a legal-approved normalized-email + hash tombstone. + +56. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, + and non-KiloClaw subscriptions MUST NOT qualify. + +57. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. + +58. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. + +59. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin + adjustments, or referral touch capture MUST NOT qualify as a paid referral conversion. + +60. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. + +61. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the + same Kilo user. + +62. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an + authorized operator explicitly marks the conversion as eligible under a documented support process. + +### Referrer Eligibility + +63. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. + +64. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. + +65. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep + the reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw + subscription. + +66. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not + started or reactivated an eligible paid personal KiloClaw subscription. + +67. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary + after the referrer starts or reactivates a paid personal KiloClaw subscription. + +68. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. + +69. The referrer cap MUST be enforced before granting a referrer reward. + +70. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST + NOT produce more than 12 granted referrer reward months. + +71. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the + referrer reward was cap-limited and MUST NOT grant another referrer free month. + +72. Referee rewards MUST NOT count against the referrer's 12-month cap. + +### Reward Granting + +73. A qualified referral conversion MUST grant one free-month reward to the referee. + +74. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked + cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents + it. + +75. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT + create duplicate rewards for the same beneficiary role. + +76. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both + beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. + +77. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of + months granted, status, and relevant timestamps. + +78. Reward records MUST support the reward states defined in this spec. + +79. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been + updated to delay the corresponding KiloClaw renewal. + +80. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of + truth for local free-month fulfillment. + +### Reward Fulfillment and Billing + +81. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. + +82. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT + modify already-finalized invoices or already-funded periods. + +83. Free-month rewards MUST NOT be fulfilled as general account credits. + +84. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team + plans, or non-KiloClaw purchases. + +85. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. + +86. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. + +87. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar + month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one + sixth. + +88. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal + sweep does not deduct KiloClaw hosting credits until the extended renewal time. + +89. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe + billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription + while allowing Stripe to charge on the original schedule. + +90. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than + once for the same reward. + +91. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous + renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. + +92. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid + invoice settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or + destruction. + +93. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward + application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw + subscription. + +### Impact Conversion Reporting + +94. Impact Advocate referral conversion MUST be driven by existing Impact Performance conversion events. + +95. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. + +96. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is + the configured reporting mechanism. + +97. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the + required Performance conversion data for Advocate conversion reporting. + +98. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate + Impact actions. + +99. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures + MUST leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is + marked permanently failed by an operator-visible terminal state. + +### Impact Reconciliation + +100. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing + fulfillment, or reconciliation. + +101. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. + +102. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, + cap, attribution, or billing fulfillment rules. + +### Refunds, Reversals, and Fraud + +103. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. + +104. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. + +105. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be + automatically canceled or clawed back. + +106. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from + refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or + clawed back. + +107. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of + creating an unrelated negative conversion. + +108. Reversal and reward-cancellation handling MUST be idempotent. + +### GDPR and PII + +109. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or + reconciliation payloads MUST be included in GDPR soft-delete or anonymization flows. + +110. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship + records, reconciliation payloads containing PII, and reward records to the extent required by policy. + +111. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. + +112. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The + system MUST NOT retain PII solely for this purpose. + +113. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary + PII. + +### Reliability and Isolation + +114. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward + fulfillment failures MUST NOT break unrelated product functionality. + +115. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing + audit records. + +116. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility + or configuration failure. + +117. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact + conversion reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, + review-required rewards, and disqualified referrals. + +118. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral + rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. + +### Existing Internal Referral System + +119. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions + already governed by this spec. + +120. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or + migrated into this program's rules to prevent double rewards. + +### Impact Reward Redemption + +121. When a local free-month reward is applied to KiloClaw billing, the system MUST mark the corresponding Impact + Advocate credit reward as redeemed so Impact reporting matches Kilo's fulfillment state. + +122. Impact Advocate reward redemption MUST happen asynchronously and MUST NOT block reward application, billing + settlement, or user access. + +123. Before redeeming an Impact Advocate reward, the system MUST fetch the beneficiary account's rewards from Impact + Advocate and select the corresponding credit reward ID. + +124. Redeeming an Impact Advocate reward MUST use Impact Advocate's single-reward redemption endpoint with the local + reward's granted month count and the configured free-month reward unit. + +125. Impact Advocate reward lookup and redemption attempts MUST be idempotently queued per local reward. + +126. If the Impact reward is not yet visible when redemption is attempted, the system MUST leave the redemption work in + a retryable state. + +127. Impact reward redemption state is for reporting and reconciliation only. It MUST NOT be the source of truth for + local reward eligibility, application, cancellation, or reversal. + +## Error Handling + +1. If referral touch capture fails, the system SHOULD log the failure and continue the primary request. + +2. If Register Participant delivery fails with a server error or timeout, the system MUST leave the registration in a + retryable state. + +3. If Register Participant delivery fails with a client error, the system MUST log the error and MUST NOT retry + unchanged payloads. + +4. If Impact conversion reporting fails with a server error or timeout, the system MUST leave the report in a retryable + state. + +5. If Impact conversion reporting fails with a client error, the system MUST log the error and MUST NOT retry unchanged + payloads. + +6. If reward grant processing detects an ineligible referee, ineligible referrer, expired attribution, self-referral, + exceeded cap, or non-personal subscription, the system MUST record the disqualification reason when a referral record + exists. + +7. If reward application fails after a reward is earned, the reward MUST remain retryable unless the failure is + permanent and auditable. + +8. If required billing state is ambiguous, the system MUST NOT apply a reward. It MUST leave the reward pending and log + the ambiguity for investigation. + +9. If Impact Advocate reward lookup or redemption fails with a server error or timeout, the system MUST leave the + redemption work in a retryable state. + +10. If Impact Advocate reward lookup or redemption fails with a client error, the system MUST log the error and MUST NOT + retry unchanged payloads, except an already-redeemed response MAY be treated as idempotent success. + +## Changelog + +### 2026-05-06 -- Redeem applied rewards in Impact Advocate + +Added rules requiring local free-month reward application to enqueue asynchronous Impact Advocate reward lookup and +single-reward redemption, including retry behavior when rewards are not yet visible and idempotent handling for already +redeemed rewards. + +### 2026-04-21 -- Initial spec + +Created source-of-truth rules for the KiloClaw referral program using Impact Advocate. Defined program identifiers, +Advocate widget and participant registration requirements, referral-priority attribution over affiliate attribution, +exact 30-day UTC expiration semantics, brand-new and previously deleted user boundaries, first-paid monetized KiloClaw +conversion, double-sided free-month rewards, referrer 12-month cap, atomic reward decisions, pending rewards for +inactive referrers, next-unpaid-renewal reward application, app-owned billing fulfillment, Impact reconciliation +behavior, no Advocate webhook reliance, retryable failure states, tracking-value limits, support-review state, GDPR +handling, Impact identity mapping, and Stripe chargeback reward cancellation. diff --git a/AGENTS.md b/AGENTS.md index 3f31170073..cd19621320 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ Target a specific test file: `pnpm test -- `. Run tests for a specific ser **Before running tests**, ensure the test database is running. If there is no active Postgres instance, run `pnpm test:db` first — this starts the Postgres container and applies migrations. You can check whether Postgres is already running with `docker compose -f dev/docker-compose.yml ps postgres`. +## apps/web UI Work + +Before making or reviewing UI changes under `apps/web` — components, routes/pages, layouts, styling, Storybook, visual polish, UX copy, interaction states, responsive behavior, theming, or accessibility — read `design.md` and use `.agents/skills/kilo-design/SKILL.md`. This applies even when the prompt does not explicitly mention design. Skip only for backend-only or non-visual logic changes. + ## Coding Standards - Prefer `type` over `interface`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 03ed4e516e..8c5c4b63a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,19 @@ pnpm drizzle migrate You need to re-run this every time you pull new migrations from the repository. +If you want to fully reset the local dev database first, use: + +```bash +pnpm dev:db:reset +pnpm drizzle migrate +``` + +To smoke-test that migrations still bootstrap correctly from a fresh empty database, run: + +```bash +pnpm drizzle:verify-bootstrap +``` + ### 6. Start the development server ```bash @@ -189,21 +202,23 @@ All tests should pass against the local PostgreSQL database. ## Common Development Commands -| Command | Description | -| -------------------------- | ------------------------------------------------------------------------------------------------- | -| `pnpm dev:start` | Start all local services in a tmux dashboard | -| `pnpm dev:stop` | Stop the tmux session and all services | -| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) | -| `pnpm test` | Run the Jest test suite | -| `pnpm typecheck` | Run the TypeScript type checker | -| `pnpm lint` | Lint all source files | -| `pnpm format` | Format all supported files with oxfmt | -| `pnpm format:changed` | Format only files changed since `main` | -| `pnpm validate` | Run typecheck, lint, and tests | -| `pnpm drizzle migrate` | Apply pending database migrations | -| `pnpm drizzle generate` | Generate a new migration after schema changes | -| `pnpm --filter web stripe` | Start Stripe webhook forwarding to localhost | -| `pnpm test:e2e` | Run Playwright end-to-end tests | +| Command | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `pnpm dev:start` | Start all local services in a tmux dashboard | +| `pnpm dev:stop` | Stop the tmux session and all services | +| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) | +| `pnpm test` | Run the Jest test suite | +| `pnpm typecheck` | Run the TypeScript type checker | +| `pnpm lint` | Lint all source files | +| `pnpm format` | Format all supported files with oxfmt | +| `pnpm format:changed` | Format only files changed since `main` | +| `pnpm validate` | Run typecheck, lint, and tests | +| `pnpm drizzle migrate` | Apply pending database migrations | +| `pnpm drizzle generate` | Generate a new migration after schema changes | +| `pnpm drizzle:verify-bootstrap` | Create a temporary empty database and verify `pnpm drizzle migrate` bootstraps it cleanly | +| `pnpm dev:db:reset` | Drop all app-owned schemas in the local dev database, recreate `public`, and leave the DB truly empty before re-migrating | +| `pnpm --filter web stripe` | Start Stripe webhook forwarding to localhost | +| `pnpm test:e2e` | Run Playwright end-to-end tests | ## Git Workflow diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000000..4efac6c5be --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md + +## UI Design Requirements + +When doing UI work in `apps/web` — including React components, routes/pages, layouts, styling, Storybook stories, visual polish, UX copy, interaction states, responsive behavior, theming, or accessibility — you must: + +1. Read `../../design.md` before changing or reviewing UI. +2. Load and follow the `kilo-design` skill at `../../.agents/skills/kilo-design/SKILL.md`. +3. Prefer existing Kilo tokens, components, and utilities before adding new visual primitives. + +This applies even when the prompt does not explicitly mention design. Skip only for backend-only or non-visual logic changes. diff --git a/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx b/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx index 349d7d5c72..a12c0926cf 100644 --- a/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useTRPC } from '@/lib/trpc/utils'; import { Button } from '@/components/ui/button'; +import KiloCrabIcon from '@/components/KiloCrabIcon'; import { BillingBanner } from './BillingBanner'; import { AccessLockedDialog } from './AccessLockedDialog'; import { PlanSelectionDialog } from './PlanSelectionDialog'; @@ -20,7 +21,7 @@ function EarlybirdActiveCard({ }) { return (
- 🦀 +
Thanks for being an early KiloClaw subscriber. diff --git a/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx b/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx index 36eb154c59..cc8afe54fc 100644 --- a/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx @@ -2,6 +2,7 @@ import { Gift } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import KiloCrabIcon from '@/components/KiloCrabIcon'; import { formatBillingDate, type ClawBillingStatus } from './billing-types'; type EarlybirdCardProps = { @@ -17,7 +18,7 @@ export function EarlybirdCard({ earlybird, onSubscribeClick }: EarlybirdCardProp
- 🦀 + KiloClaw Hosting
@@ -59,7 +60,7 @@ export function EarlybirdCard({ earlybird, onSubscribeClick }: EarlybirdCardProp
- 🦀 + KiloClaw Hosting
diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts new file mode 100644 index 0000000000..caff4dcb7e --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts @@ -0,0 +1,130 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { ReferralRewardStatusCard } from './ReferralRewardStatusCard'; + +const emptySummary = { + totals: { + totalRewards: 0, + pendingRewards: 0, + totalAppliedMonths: 0, + }, + pendingRewardAction: { + showStartReactivateCta: false, + pendingRewardCount: 0, + }, + referredPeople: [], + rewards: [], +}; + +describe('ReferralRewardStatusCard', () => { + it('renders the empty state with a referral-share CTA and no warning state', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { summary: emptySummary }) + ); + + expect(html).toContain('No referral rewards yet.'); + expect(html).toContain('href="#referral-share"'); + expect(html).not.toContain('data-testid="summary-indicator-warning"'); + expect(html).not.toContain('credits'); + expect(html).not.toContain('awards'); + }); + + it('renders the Impact share widget slot when provided', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { + summary: emptySummary, + shareWidget: React.createElement('div', { 'data-testid': 'share-widget' }, 'widget body'), + }) + ); + + expect(html).toContain('id="referral-share"'); + expect(html).toContain('data-testid="share-widget"'); + expect(html).toContain('widget body'); + }); + + it('surfaces on-hold rewards, applied renewal dates, and customer-safe referee status', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { + summary: { + totals: { + totalRewards: 2, + pendingRewards: 1, + totalAppliedMonths: 1, + }, + pendingRewardAction: { + showStartReactivateCta: true, + pendingRewardCount: 1, + }, + referredPeople: [ + { + maskedEmail: 'q***@example.com', + state: 'reward_granted', + rewardGranted: true, + }, + { + maskedEmail: 's***@example.com', + state: 'waiting_for_paid_conversion', + rewardGranted: false, + }, + ], + rewards: [ + { + role: 'referrer', + status: 'applied', + monthsGranted: 1, + earnedAt: '2026-04-10T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + expiresAt: null, + reviewReason: null, + application: { + appliedAt: '2026-04-10T00:05:00.000Z', + subscriptionId: '11111111-1111-4111-8111-111111111111', + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + }, + { + role: 'referee', + status: 'pending', + monthsGranted: 1, + earnedAt: '2026-04-11T00:00:00.000Z', + appliedAt: null, + expiresAt: null, + reviewReason: null, + application: null, + }, + ], + }, + }) + ); + + expect(html).toContain('1 reward on hold'); + expect(html).toContain('Start or reactivate KiloClaw'); + expect(html).toContain('data-testid="summary-indicator-warning"'); + expect(html).toContain('Applied'); + expect(html).toContain('May 1, 2026'); + expect(html).toContain('June 1, 2026'); + expect(html).toContain('Waiting for an eligible KiloClaw subscription'); + expect(html).toContain('q***@example.com'); + expect(html).toContain('Reward granted'); + expect(html).toContain('s***@example.com'); + expect(html).toContain('Signed up, waiting for paid KiloClaw conversion'); + }); + + it('pluralizes the reactivate banner copy when more than one reward is pending', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { + summary: { + ...emptySummary, + totals: { totalRewards: 2, pendingRewards: 2, totalAppliedMonths: 0 }, + pendingRewardAction: { showStartReactivateCta: true, pendingRewardCount: 2 }, + }, + }) + ); + + expect(html).toContain('2 rewards on hold'); + expect(html).toContain('to apply them'); + }); +}); diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx new file mode 100644 index 0000000000..a7bb2c9771 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx @@ -0,0 +1,330 @@ +import React from 'react'; +import Link from 'next/link'; +import { CalendarDays, Gift, Info, Sparkles } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { formatBillingDate } from './billing-types'; + +const SHARE_WIDGET_ANCHOR_ID = 'referral-share'; + +type ReferralRewardStatus = + | 'pending' + | 'earned' + | 'applied' + | 'expired' + | 'canceled' + | 'reversed' + | 'review_required'; + +type ReferralRewardSummary = { + totals: { + totalRewards: number; + pendingRewards: number; + totalAppliedMonths: number; + }; + pendingRewardAction: { + showStartReactivateCta: boolean; + pendingRewardCount: number; + }; + referredPeople: Array<{ + maskedEmail: string | null; + state: 'reward_granted' | 'waiting_for_paid_conversion'; + rewardGranted: boolean; + }>; + rewards: Array<{ + role: 'referrer' | 'referee'; + status: ReferralRewardStatus; + monthsGranted: number; + earnedAt: string; + appliedAt: string | null; + expiresAt: string | null; + reviewReason: string | null; + application: { + appliedAt: string; + subscriptionId: string | null; + previousRenewalBoundary: string; + newRenewalBoundary: string; + } | null; + }>; +}; + +type ReferralRewardStatusCardProps = { + summary: ReferralRewardSummary; + shareWidget?: React.ReactNode; +}; + +type StatusPresentation = { + label: string; + className: string; +}; + +function rewardStatusPresentation(status: ReferralRewardStatus): StatusPresentation { + switch (status) { + case 'applied': + return { + label: 'Applied', + className: 'bg-emerald-500/20 text-emerald-400 ring-emerald-500/20', + }; + case 'earned': + return { + label: 'Waiting for renewal extension', + className: 'bg-blue-500/20 text-blue-400 ring-blue-500/20', + }; + case 'pending': + return { + label: 'Waiting for an eligible KiloClaw subscription', + className: 'bg-yellow-500/20 text-yellow-400 ring-yellow-500/20', + }; + case 'expired': + return { + label: 'Expired', + className: 'bg-zinc-500/20 text-zinc-400 ring-zinc-500/20', + }; + case 'canceled': + return { + label: 'Canceled', + className: 'bg-zinc-500/20 text-zinc-400 ring-zinc-500/20', + }; + case 'reversed': + return { + label: 'Reversed', + className: 'bg-red-500/20 text-red-400 ring-red-500/20', + }; + case 'review_required': + return { + label: 'Needs review', + className: 'bg-orange-500/20 text-orange-400 ring-orange-500/20', + }; + } +} + +function roleLabel(role: 'referrer' | 'referee'): string { + return role === 'referrer' ? 'Referral you shared' : 'Referral you used'; +} + +function monthLabel(months: number): string { + return `${months} ${months === 1 ? 'free month' : 'free months'}`; +} + +export function ReferralRewardStatusCard({ summary, shareWidget }: ReferralRewardStatusCardProps) { + const showReactivateCta = summary.pendingRewardAction.showStartReactivateCta; + + return ( + + + + + + Share KiloClaw with someone else and when they sign up for a paid subscription, you both + get 1 free month of KiloClaw hosting. + + + + {shareWidget ?
{shareWidget}
: null} + + {showReactivateCta ? ( +
+
+ You have {summary.pendingRewardAction.pendingRewardCount}{' '} + {summary.pendingRewardAction.pendingRewardCount === 1 ? 'reward' : 'rewards'} on hold. + Start or reactivate KiloClaw to apply{' '} + {summary.pendingRewardAction.pendingRewardCount === 1 ? 'it' : 'them'}. +
+ +
+ ) : null} + +
+ + 0 ? 'warning' : undefined} + /> + +
+ +
+

+ Earned rewards +

+ + {summary.rewards.length === 0 ? ( +
+ No referral rewards yet.{' '} + + Share your referral link + {' '} + to earn a free month. +
+ ) : ( +
+ {summary.rewards.map((reward, index) => ( + + ))} +
+ )} +
+ +
+

+ Your referees +

+ + {summary.referredPeople.length === 0 ? ( +
+ No referred people yet. +
+ ) : ( +
+ {summary.referredPeople.map((person, index) => ( +
+
+
+ {person.maskedEmail ?? 'Unknown referee'} +
+
Masked referee identity
+
+ + {person.state === 'reward_granted' + ? 'Reward granted' + : 'Signed up, waiting for paid KiloClaw conversion'} + +
+ ))} +
+ )} +
+
+
+ ); +} + +type IndicatorTone = 'warning'; + +function SummaryTile({ + label, + value, + info, + indicator, +}: { + label: string; + value: string; + info?: string; + indicator?: IndicatorTone; +}) { + return ( +
+
+ {indicator === 'warning' ? ( +
+
+ {value} +
+
+ ); +} + +function RewardRow({ reward }: { reward: ReferralRewardSummary['rewards'][number] }) { + const status = rewardStatusPresentation(reward.status); + return ( +
+
+
{roleLabel(reward.role)}
+
+
+
+
+ + {status.label} + + + {monthLabel(reward.monthsGranted)} +
+
+ {reward.application ? ( + <> +
+
+
+ Renewal moved{' '} + + {formatBillingDate(reward.application.previousRenewalBoundary)} + {' '} + to{' '} + + {formatBillingDate(reward.application.newRenewalBoundary)} + +
+ + ) : reward.expiresAt ? ( +
+ Expires{' '} + {formatBillingDate(reward.expiresAt)} +
+ ) : ( +
Reward application details appear after the free month is applied.
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts new file mode 100644 index 0000000000..866785d436 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { ReferralRewardsSummary } from './ReferralRewardsSummary'; +import type { ClawBillingStatus } from './billing-types'; + +const emptyRewards: NonNullable['referralRewards'] = { + totalAppliedMonths: 0, + applications: [], +}; + +describe('ReferralRewardsSummary', () => { + it('renders the empty state with a primary refer-a-friend CTA', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { rewards: emptyRewards }) + ); + + expect(html).toContain('No rewards yet. Refer a friend to earn a free month.'); + expect(html).toContain('href="/claw/refer"'); + }); + + it('renders applied referrer and referee rewards with renewal boundaries', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { + rewards: { + totalAppliedMonths: 2, + applications: [ + { + role: 'referrer', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + { + role: 'referee', + appliedAt: '2026-04-11T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-06-01T00:00:00.000Z', + newRenewalBoundary: '2026-07-01T00:00:00.000Z', + }, + ], + }, + }) + ); + + expect(html).toContain('2 free months applied'); + expect(html).toContain('Reward for referring'); + expect(html).toContain('Welcome reward'); + expect(html).toContain('May 1, 2026'); + expect(html).toContain('July 1, 2026'); + }); + + it('drops its own border when rendered as a section variant', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { + rewards: emptyRewards, + variant: 'section', + }) + ); + + // The card variant has bg-background/40; the section variant must not. + expect(html).not.toContain('bg-background/40'); + expect(html).toContain('border-t'); + }); +}); diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx new file mode 100644 index 0000000000..d17fa21e78 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import Link from 'next/link'; +import { ArrowRight, CalendarDays, Gift } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { ClawBillingStatus } from './billing-types'; +import { formatBillingDate } from './billing-types'; + +type ReferralRewards = NonNullable['referralRewards']; + +type ReferralRewardsSummaryProps = { + rewards: ReferralRewards; + /** + * `card` (default) renders inside its own bordered container. Use this when + * the summary stands alone as a sibling card on a detail page. + * `section` renders as a flat block separated by a top divider — use it when + * embedding inside another `` to avoid the nested-card anti-pattern. + */ + variant?: 'card' | 'section'; +}; + +function roleLabel(role: ReferralRewards['applications'][number]['role']): string { + // Address the user, not the system. "Referee" is internal jargon. + return role === 'referrer' ? 'Reward for referring' : 'Welcome reward'; +} + +function monthsLabel(months: number): string { + return `${months} ${months === 1 ? 'free month' : 'free months'}`; +} + +export function ReferralRewardsSummary({ rewards, variant = 'card' }: ReferralRewardsSummaryProps) { + const isCard = variant === 'card'; + + return ( +
+
+
+
+
+
+

+ Referral rewards +

+

+ Free months push your renewal date out. +

+
+
+ {rewards.totalAppliedMonths > 0 ? ( + + {monthsLabel(rewards.totalAppliedMonths)} applied + + ) : null} +
+ + {rewards.applications.length === 0 ? ( +
+

No rewards yet. Refer a friend to earn a free month.

+ +
+ ) : ( +
    + {rewards.applications.map(application => ( +
  • +
    +
    {roleLabel(application.role)}
    +
    + {monthsLabel(application.monthsGranted)} +
    +
    +
    + + + + Renewal: + + {formatBillingDate(application.previousRenewalBoundary)} + + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx b/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx index 343183067a..b3f3e2062d 100644 --- a/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx @@ -1,11 +1,32 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import Link from 'next/link'; -import { ExternalLink, CreditCard, Coins, Loader2 } from 'lucide-react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; + import { useTRPC } from '@/lib/trpc/utils'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import KiloCrabIcon from '@/components/KiloCrabIcon'; +import { DetailRow } from '@/components/subscriptions/DetailRow'; +import { formatPaymentSummary } from '@/components/subscriptions/helpers'; +import { SubscriptionStatusBadge } from '@/components/subscriptions/SubscriptionStatusBadge'; +import { useInvalidateKiloClawBilling } from '@/components/subscriptions/kiloclaw/useKiloClawBillingQueries'; +import { cn } from '@/lib/utils'; + import { COMMIT_PERIOD_MONTHS, formatBillingDate, @@ -14,34 +35,28 @@ import { planLabel, type ClawBillingStatus, } from './billing-types'; +import { ReferralRewardsSummary } from './ReferralRewardsSummary'; type SubscriptionCardProps = { billing: ClawBillingStatus; onCancelClick: () => void; }; -function PaymentSourceBadge({ - subscription, -}: { - subscription: NonNullable; -}) { - if (subscription.hasStripeFunding) { - return ( - - - Stripe - - ); - } - if (subscription.paymentSource === 'credits') { - return ( - - - Credits - - ); - } - return null; +type ShellStatus = 'active' | 'pending_settlement' | 'past_due' | 'unpaid' | 'pending_cancellation'; + +function KiloClawCardShell({ status, children }: { status: ShellStatus; children: ReactNode }) { + return ( + + + + + KiloClaw subscription + + + + {children} + + ); } function PendingSettlementSubscriptionCard({ billing }: { billing: ClawBillingStatus }) { @@ -49,30 +64,28 @@ function PendingSettlementSubscriptionCard({ billing }: { billing: ClawBillingSt if (!sub) return null; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- -
- -
- -
-
- Status: Processing payment -
-

- Hosting activates after invoice settlement. This usually takes just a moment. -

-
-
-
+ + + + ); } +type ActiveConfirmationAction = 'switchPlan' | 'cancelPlanSwitch' | 'switchToCredits'; + +type ActiveConfirmation = { + title: string; + description: string; + confirmLabel: string; + pendingLabel: string; + run: () => Promise; +}; + function ActiveSubscriptionCard({ billing, onCancelClick, @@ -81,8 +94,9 @@ function ActiveSubscriptionCard({ onCancelClick: () => void; }) { const trpc = useTRPC(); - const queryClient = useQueryClient(); const instanceId = billing.instance?.id ?? null; + const invalidate = useInvalidateKiloClawBilling(instanceId); + const switchPlanMutation = useMutation(trpc.kiloclaw.switchPlanAtInstance.mutationOptions()); const portalMutation = useMutation(trpc.kiloclaw.getCustomerPortalUrl.mutationOptions()); const cancelSwitchMutation = useMutation( @@ -91,6 +105,12 @@ function ActiveSubscriptionCard({ const acceptConversionMutation = useMutation( trpc.kiloclaw.acceptConversionAtInstance.mutationOptions() ); + + const [confirmationAction, setConfirmationAction] = useState( + null + ); + const [pendingAction, setPendingAction] = useState(null); + const CONVERSION_DISMISSED_KEY = 'kiloclaw-conversion-dismissed'; const [conversionDismissed, setConversionDismissed] = useState(() => { if (typeof window === 'undefined') return false; @@ -98,43 +118,27 @@ function ActiveSubscriptionCard({ }); const sub = billing.subscription; + + useEffect(() => { + if (sub && !sub.showConversionPrompt && conversionDismissed) { + localStorage.removeItem(CONVERSION_DISMISSED_KEY); + setConversionDismissed(false); + } + }, [sub, conversionDismissed]); + if (!sub) return null; const isCommit = sub.plan === 'commit'; - const currentPlanLabel = planLabel(sub.plan); + const otherPlan = isCommit ? 'standard' : 'commit'; const otherPlanLabel = isCommit ? `Standard ($${PLAN_DISPLAY.standard.monthlyDollars}/mo)` : `Commit ($${PLAN_DISPLAY.commit.monthlyDollars}/mo · ${COMMIT_PERIOD_MONTHS}-mo term)`; const hasUserRequestedSwitch = sub.scheduledBy === 'user'; - - async function invalidateBillingQueries() { - if (!instanceId) return; - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getActivePersonalBillingStatus.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getPersonalBillingSummary.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), - }), - ]); - } - - async function handleSwitchPlan() { - if (!instanceId) return; - const toPlan = isCommit ? 'standard' : 'commit'; - await switchPlanMutation.mutateAsync({ instanceId, toPlan }); - await invalidateBillingQueries(); - } + const isCreditFunded = !sub.hasStripeFunding && sub.paymentSource === 'credits'; + const renewalDate = + isCreditFunded && sub.creditRenewalAt ? sub.creditRenewalAt : sub.currentPeriodEnd; + const showConversion = sub.showConversionPrompt && !conversionDismissed; async function handleManageBilling() { if (!instanceId) return; @@ -145,154 +149,205 @@ function ActiveSubscriptionCard({ window.location.href = result.url; } - async function handleCancelSwitch() { - if (!instanceId) return; - await cancelSwitchMutation.mutateAsync({ instanceId }); - await invalidateBillingQueries(); - } - - async function handleAcceptConversion() { - if (!instanceId) return; - await acceptConversionMutation.mutateAsync({ instanceId }); - await invalidateBillingQueries(); + const confirmations: Record = { + switchPlan: { + title: `Switch to ${isCommit ? 'Standard' : 'Commit'}?`, + description: `Schedules your KiloClaw subscription to switch plans at the next renewal. Your current plan stays active until then.`, + confirmLabel: `Switch to ${otherPlan}`, + pendingLabel: `Switching to ${otherPlan}`, + run: async () => { + if (!instanceId) return; + await switchPlanMutation.mutateAsync({ instanceId, toPlan: otherPlan }); + }, + }, + cancelPlanSwitch: { + title: 'Cancel scheduled plan switch?', + description: + 'Keeps your KiloClaw subscription on its current plan and removes the pending change.', + confirmLabel: 'Cancel plan switch', + pendingLabel: 'Canceling plan switch', + run: async () => { + if (!instanceId) return; + await cancelSwitchMutation.mutateAsync({ instanceId }); + }, + }, + switchToCredits: { + title: 'Switch hosting billing to credits?', + description: + 'Stripe billing stays active through the current period, then this subscription renews against your credit balance.', + confirmLabel: 'Switch to Credits', + pendingLabel: 'Switching to credits', + run: async () => { + if (!instanceId) return; + await acceptConversionMutation.mutateAsync({ instanceId }); + }, + }, + }; + + const activeConfirmation = confirmationAction ? confirmations[confirmationAction] : null; + const isPending = pendingAction !== null; + + function confirmCurrentAction() { + if (!confirmationAction || !activeConfirmation) return; + setPendingAction(confirmationAction); + void (async () => { + try { + await activeConfirmation.run(); + setConfirmationAction(null); + try { + await invalidate(); + } catch (error) { + console.error('[kiloclaw-billing] failed to refresh after confirmation action', error); + toast.error('Action completed, but billing did not refresh. Refresh the page.'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Action failed. Try again.'); + } finally { + setPendingAction(null); + } + })(); } - // Clear the persisted dismiss when the prompt is no longer relevant - // (e.g. user converted, subscription changed) so it doesn't stay hidden forever. - useEffect(() => { - if (!sub.showConversionPrompt && conversionDismissed) { - localStorage.removeItem(CONVERSION_DISMISSED_KEY); - setConversionDismissed(false); - } - }, [sub.showConversionPrompt, conversionDismissed]); - - const showConversion = sub.showConversionPrompt && !conversionDismissed; - - // Credit-funded renewal info - const isCreditFunded = !sub.hasStripeFunding && sub.paymentSource === 'credits'; - const renewalDate = - isCreditFunded && sub.creditRenewalAt ? sub.creditRenewalAt : sub.currentPeriodEnd; - return ( -
-
-
- 🦀 - KiloClaw Subscription -
- + +
+ + + + {isCommit ? ( + + ) : null} + {isCreditFunded && sub.renewalCostMicrodollars != null ? ( + + ) : null}
-
-
- Plan: {currentPlanLabel} -
-
- Status: Active -
- {isCommit && sub.commitEndsAt ? ( - <> -
- Commit period ends:{' '} - {formatBillingDate(sub.commitEndsAt)} -
-
(Auto-renews for another {COMMIT_PERIOD_MONTHS} months)
- - ) : ( -
- Next billing:{' '} - {formatBillingDate(renewalDate)} -
- )} - {isCreditFunded && sub.renewalCostMicrodollars != null && ( -
- Renewal cost:{' '} - - {formatMicrodollars(sub.renewalCostMicrodollars)} from credit balance - -
- )} - {hasUserRequestedSwitch && ( -
+ {hasUserRequestedSwitch ? ( + + Switching to {isCommit ? 'Standard' : 'Commit'} on{' '} - {formatBillingDate(sub.currentPeriodEnd)} -
- )} -
+ {formatBillingDate(sub.currentPeriodEnd)}. + + + ) : null} + + {showConversion ? ( + + +

+ You have an active Kilo Pass. Switch hosting to credit-funded billing to stop the + separate Stripe charge — your current period continues as-is. +

+
+ + +
+
+
+ ) : null} - {showConversion && ( -
-

- You have an active Kilo Pass. Switch hosting to credit-funded billing to stop the - separate Stripe charge — your current period continues as-is. -

-
- - -
-
- )} + -
+
{hasUserRequestedSwitch ? ( ) : ( )} - - {sub.hasStripeFunding && ( - - )} + ) : null} +
-
+ + { + if (!open && !isPending) setConfirmationAction(null); + }} + > + + + {activeConfirmation?.title} + {activeConfirmation?.description} + + + Cancel + + {isPending ? activeConfirmation?.pendingLabel : activeConfirmation?.confirmLabel} + + + + +
); } @@ -309,47 +364,39 @@ function ConvertingSubscriptionCard({ if (!sub) return null; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- - - Switching to Credits - + +
+ + +
-
-
- Plan: {planLabel(sub.plan)} -
-
- Status:{' '} - - Switches to credit billing on {formatBillingDate(sub.currentPeriodEnd)} - -
-

+ + Your Stripe charge ends at the current period. After that, hosting renews from your credit balance. -

-
+ + -
-
-
+ ); } @@ -366,40 +413,42 @@ function CancelingSubscriptionCard({ if (!sub) return null; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- + +
+ + +
-
-
- Plan: {planLabel(sub.plan)} -
-
- Status:{' '} - - Cancels on {formatBillingDate(sub.currentPeriodEnd)} - -
-
+ + + Your subscription cancels on{' '} + {formatBillingDate(sub.currentPeriodEnd)}. + Reactivate to keep it renewing. + + -
-
-
+ ); } @@ -414,80 +463,64 @@ function PastDueSubscriptionCard({ if (!sub) return null; const isCreditFunded = !sub.hasStripeFunding && sub.paymentSource === 'credits'; + const status: ShellStatus = sub.status === 'unpaid' ? 'unpaid' : 'past_due'; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- + +
+ +
-
-
- Status: Payment Failed -
-

+ + Payment failed + {isCreditFunded ? 'Your credit balance is insufficient for the next renewal. Add credits to avoid service interruption.' : 'Your last payment failed. Update your payment method to avoid service interruption.'} -

-
+ + -
+ + +
{isCreditFunded ? ( ) : ( )}
-
+
); } export function SubscriptionCard({ billing, onCancelClick }: SubscriptionCardProps) { const trpc = useTRPC(); - const queryClient = useQueryClient(); const instanceId = billing.instance?.id ?? null; + const invalidate = useInvalidateKiloClawBilling(instanceId); + const reactivateMutation = useMutation( trpc.kiloclaw.reactivateSubscriptionAtInstance.mutationOptions() ); const portalMutation = useMutation(trpc.kiloclaw.getCustomerPortalUrl.mutationOptions()); - async function invalidateBillingQueries() { - if (!instanceId) return; - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getActivePersonalBillingStatus.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getPersonalBillingSummary.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), - }), - ]); - } - function handleReactivate() { if (!instanceId || reactivateMutation.isPending) return; reactivateMutation.mutate( { instanceId }, { onSuccess: () => { - void invalidateBillingQueries(); + void invalidate(); }, } ); diff --git a/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts b/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts index 78b08638ec..4601d76a08 100644 --- a/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts +++ b/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts @@ -50,6 +50,10 @@ function createBillingStatus(overrides?: BillingStatusOverrides): ClawBillingSta renewalCostMicrodollars: null, showConversionPrompt: false, pendingConversion: false, + referralRewards: { + totalAppliedMonths: 0, + applications: [], + }, ...subscriptionOverrides, }, earlybird: null, diff --git a/apps/web/src/app/(app)/claw/components/billing/billing-types.ts b/apps/web/src/app/(app)/claw/components/billing/billing-types.ts index 4d6e7a35ed..a7f864801e 100644 --- a/apps/web/src/app/(app)/claw/components/billing/billing-types.ts +++ b/apps/web/src/app/(app)/claw/components/billing/billing-types.ts @@ -101,6 +101,16 @@ export type ClawBillingStatus = { showConversionPrompt: boolean; /** True when Stripe subscription is being cancelled to convert to credit-funded billing. */ pendingConversion: boolean; + referralRewards: { + totalAppliedMonths: number; + applications: Array<{ + role: 'referrer' | 'referee'; + appliedAt: string; + monthsGranted: number; + previousRenewalBoundary: string; + newRenewalBoundary: string; + }>; + }; } | null; earlybird: { diff --git a/apps/web/src/app/(app)/claw/refer/page.tsx b/apps/web/src/app/(app)/claw/refer/page.tsx new file mode 100644 index 0000000000..2866525f2b --- /dev/null +++ b/apps/web/src/app/(app)/claw/refer/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { Gift } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +import { SetPageTitle } from '@/components/SetPageTitle'; +import { Card, CardContent } from '@/components/ui/card'; +import { useTRPC } from '@/lib/trpc/utils'; +import { ImpactAdvocateReferralWidget } from '@/components/referrals/ImpactAdvocateReferralCard'; +import { ReferralRewardStatusCard } from '../components/billing/ReferralRewardStatusCard'; + +const emptyRewardSummary = { + totals: { + totalRewards: 0, + pendingRewards: 0, + totalAppliedMonths: 0, + }, + pendingRewardAction: { + showStartReactivateCta: false, + pendingRewardCount: 0, + }, + referredPeople: [], + rewards: [], +}; + +export default function PersonalClawReferPage() { + const trpc = useTRPC(); + const rewardSummary = useQuery(trpc.kiloclaw.getReferralRewardSummary.queryOptions()); + + return ( +
+ } + /> + {rewardSummary.isLoading ? ( + + Loading rewards… + + ) : ( + } + /> + )} +
+ ); +} diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index 2ccd33276d..8d266db392 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -30,6 +30,7 @@ import { CreditCard, MessageSquare, Sparkles, + Gift, ChevronLeft, ChevronRight, } from 'lucide-react'; @@ -82,6 +83,8 @@ export default function PersonalAppSidebar(props: React.ComponentProps = [ { @@ -104,6 +107,13 @@ export default function PersonalAppSidebar(props: React.ComponentProps void; isActive?: boolean; suffixIcon?: React.ElementType; + subtitle?: string; + badge?: string; className?: string; }; @@ -60,20 +64,35 @@ export default function SidebarMenuList({ const content = ( <> - {item.title} + {item.subtitle ? ( + + {item.title} + + {item.subtitle} + + + ) : ( + {item.title} + )} {item.suffixIcon && } ); + const buttonClassName = cn( + 'flex items-center gap-3 transition-colors', + item.subtitle && 'h-12 py-2', + item.badge && 'pr-14', + item.className + ); return ( {item.url ? ( - - + + {content} @@ -82,11 +101,17 @@ export default function SidebarMenuList({ type="button" onClick={item.onClick} isActive={isActive} - className={`flex cursor-pointer items-center gap-3 transition-colors ${item.className || ''}`} + size={item.subtitle ? 'lg' : 'default'} + className={cn('cursor-pointer', buttonClassName)} > {content} )} + {item.badge && ( + + {item.badge} + + )} ); })} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index afb80e8546..ee3e59bbb9 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -7,7 +7,6 @@ import { EventServiceProvider } from '@/contexts/EventServiceContext'; import { AdminOmnibox } from '@/components/admin-omnibox'; import { PrefetchedOrganizations } from './components/PrefetchedOrganizations'; import { PlatformPresenceMount } from './components/PlatformPresenceMount'; -import { ImpactIdentify } from '@/components/ImpactIdentify'; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( @@ -16,7 +15,6 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { -
diff --git a/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts new file mode 100644 index 0000000000..e6505ed512 --- /dev/null +++ b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + insertKiloClawSubscriptionChangeLog, + type KiloClawSubscription, + type User, +} from '@kilocode/db'; + +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; +import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; +import { getUserFromAuth } from '@/lib/user.server'; + +// Test-fixture boundary: only the fields the route actually reads are +// populated. Casting via Partial -> T (rather than `as never` or +// `as unknown as T`) keeps the structural type relationship intact, so any +// required field the route starts to read in the future will surface as a +// concrete TS error here instead of silently `undefined`. +function adminUserFixture(overrides: Partial & Pick): User { + return overrides as Partial as User; +} + +function subscriptionFixture( + overrides: Partial & Pick +): KiloClawSubscription { + return overrides as Partial as KiloClawSubscription; +} + +jest.mock('@/lib/user.server', () => ({ + getUserFromAuth: jest.fn(), +})); + +jest.mock('@/lib/kiloclaw/current-personal-subscription', () => ({ + resolveCurrentPersonalSubscriptionRow: jest.fn(), +})); + +jest.mock('@/lib/kiloclaw-referrals', () => ({ + processPersonalKiloClawPaidConversion: jest.fn(), +})); + +jest.mock('@kilocode/db', () => ({ + insertKiloClawSubscriptionChangeLog: jest.fn(), +})); + +import { POST } from './route'; + +const mockGetUserFromAuth = jest.mocked(getUserFromAuth); +const mockResolveCurrentPersonalSubscriptionRow = jest.mocked( + resolveCurrentPersonalSubscriptionRow +); +const mockProcessPersonalKiloClawPaidConversion = jest.mocked( + processPersonalKiloClawPaidConversion +); +const mockInsertKiloClawSubscriptionChangeLog = jest.mocked(insertKiloClawSubscriptionChangeLog); + +function createRequest(body: unknown) { + return new NextRequest( + 'http://localhost:3000/admin/api/users/user_123/kiloclaw-referral-eligibility', + { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json', + }, + } + ); +} + +describe('POST /admin/api/users/[id]/kiloclaw-referral-eligibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetUserFromAuth.mockResolvedValue({ + user: adminUserFixture({ id: 'admin_123' }), + authFailedResponse: null, + }); + mockResolveCurrentPersonalSubscriptionRow.mockResolvedValue({ + subscription: subscriptionFixture({ + id: 'subscription_123', + user_id: 'user_123', + plan: 'standard', + status: 'active', + }), + } as Awaited>); + mockInsertKiloClawSubscriptionChangeLog.mockResolvedValue(undefined); + mockProcessPersonalKiloClawPaidConversion.mockResolvedValue({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_123', + disqualificationReason: null, + }); + }); + + it('returns authFailedResponse for unauthorized operators', async () => { + mockGetUserFromAuth.mockResolvedValue({ + user: null, + authFailedResponse: NextResponse.json( + { success: false as const, error: 'Unauthorized' }, + { status: 401 } + ), + }); + + const response = await POST(createRequest({}), { params: Promise.resolve({ id: 'user_123' }) }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ success: false, error: 'Unauthorized' }); + }); + + it('records an admin override and processes the conversion with overrideEligible=true', async () => { + const response = await POST( + createRequest({ + sourcePaymentId: 'invoice_123', + orderId: 'invoice_123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: '2026-04-09T00:00:00.000Z', + sourceType: 'manual_adjustment', + }), + { params: Promise.resolve({ id: 'user_123' }) } + ); + + expect(mockInsertKiloClawSubscriptionChangeLog).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + subscriptionId: 'subscription_123', + actor: { + actorType: 'user', + actorId: 'admin_123', + }, + action: 'admin_override', + reason: 'referral_eligibility_override:manual_adjustment:invoice_123', + }) + ); + + expect(mockProcessPersonalKiloClawPaidConversion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_123', + sourcePaymentId: 'invoice_123', + qualificationContext: { + sourceType: 'manual_adjustment', + overrideEligible: true, + }, + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + disposition: { + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_123', + disqualificationReason: null, + }, + }); + }); + + it('returns 409 when no current personal subscription exists for the override target', async () => { + mockResolveCurrentPersonalSubscriptionRow.mockResolvedValue(null); + + const response = await POST( + createRequest({ + sourcePaymentId: 'invoice_123', + orderId: 'invoice_123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + convertedAt: '2026-04-09T00:00:00.000Z', + sourceType: 'test', + }), + { params: Promise.resolve({ id: 'user_123' }) } + ); + + expect(mockInsertKiloClawSubscriptionChangeLog).not.toHaveBeenCalled(); + expect(mockProcessPersonalKiloClawPaidConversion).not.toHaveBeenCalled(); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: 'No current personal KiloClaw subscription found for referral override', + }); + }); +}); diff --git a/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts new file mode 100644 index 0000000000..68c77d61e2 --- /dev/null +++ b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts @@ -0,0 +1,85 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; + +import { db } from '@/lib/drizzle'; +import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; +import { getUserFromAuth } from '@/lib/user.server'; + +const OverrideBodySchema = z.object({ + sourcePaymentId: z.string().min(1), + orderId: z.string().min(1), + amount: z.number().nonnegative(), + currencyCode: z.string().min(1), + itemCategory: z.string().min(1), + itemName: z.string().min(1), + itemSku: z.string().min(1).optional(), + convertedAt: z.string().datetime(), + sourceType: z.enum(['test', 'fraudulent', 'admin_created', 'manual_adjustment']), +}); + +/** + * Admin-only support route for explicitly marking an otherwise excluded + * KiloClaw referral conversion as eligible. + */ +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { user: adminUser, authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + return authFailedResponse; + } + + if (!adminUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = OverrideBodySchema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + } + + const userId = (await params).id; + const currentPersonalSubscription = await resolveCurrentPersonalSubscriptionRow({ + userId, + dbOrTx: db, + }); + if (!currentPersonalSubscription) { + return NextResponse.json( + { error: 'No current personal KiloClaw subscription found for referral override' }, + { status: 409 } + ); + } + + await insertKiloClawSubscriptionChangeLog(db, { + subscriptionId: currentPersonalSubscription.subscription.id, + actor: { + actorType: 'user', + actorId: adminUser.id, + }, + action: 'admin_override', + reason: `referral_eligibility_override:${body.data.sourceType}:${body.data.sourcePaymentId}`, + before: currentPersonalSubscription.subscription, + after: currentPersonalSubscription.subscription, + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId, + sourcePaymentId: body.data.sourcePaymentId, + orderId: body.data.orderId, + amount: body.data.amount, + currencyCode: body.data.currencyCode, + itemCategory: body.data.itemCategory, + itemName: body.data.itemName, + itemSku: body.data.itemSku, + convertedAt: new Date(body.data.convertedAt), + qualificationContext: { + sourceType: body.data.sourceType, + overrideEligible: true, + }, + }); + + return NextResponse.json({ + ok: true, + disposition, + }); +} diff --git a/apps/web/src/app/admin/components/AppSidebar.tsx b/apps/web/src/app/admin/components/AppSidebar.tsx index 049310e158..8a6faaf748 100644 --- a/apps/web/src/app/admin/components/AppSidebar.tsx +++ b/apps/web/src/app/admin/components/AppSidebar.tsx @@ -122,6 +122,11 @@ const productEngineeringItems: MenuItem[] = [ url: '/admin/kiloclaw', icon: () => , }, + { + title: () => 'KiloClaw referrals', + url: '/admin/kiloclaw-referrals', + icon: () => , + }, { title: () => 'Community PRs', url: '/admin/community-prs', diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts new file mode 100644 index 0000000000..c4ce6d9b60 --- /dev/null +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts @@ -0,0 +1,107 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { KiloclawReferralsInvestigationResults } from './KiloclawReferralsInvestigation'; + +function referralRow(params: { + referralId: string; + refereeEmail: string; + paymentId: string; + qualified: boolean; + disqualificationReason: string | null; + impactReportState: string; +}) { + return { + referral: { + id: params.referralId, + impactReferralId: 'RS-SUPPORT', + createdAt: '2026-04-01T00:00:00.000Z', + }, + referee: { id: `${params.referralId}-referee`, email: params.refereeEmail, name: null }, + sourceTouch: null, + conversion: { + id: `${params.referralId}-conversion`, + winningTouchType: 'referral', + sourcePaymentId: params.paymentId, + qualified: params.qualified, + disqualificationReason: params.disqualificationReason, + convertedAt: '2026-04-10T00:00:00.000Z', + }, + rewardDecisions: [ + { + id: `${params.referralId}-decision`, + beneficiaryUserId: 'referrer-1', + beneficiaryRole: 'referrer', + outcome: params.qualified ? 'granted' : 'disqualified', + reason: params.disqualificationReason, + monthsGranted: params.qualified ? 1 : 0, + createdAt: '2026-04-10T00:00:00.000Z', + }, + ], + rewards: [], + rewardApplications: params.qualified + ? [ + { + id: `${params.referralId}-application`, + beneficiaryUserId: 'referrer-1', + subscriptionId: '55555555-5555-4555-8555-555555555555', + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + }, + ] + : [], + impactReports: [ + { + id: `${params.referralId}-report`, + state: params.impactReportState, + actionTrackerId: 71659, + orderId: params.paymentId, + deliveredAt: params.impactReportState === 'delivered' ? '2026-04-10T00:06:00.000Z' : null, + nextRetryAt: null, + responseStatusCode: params.impactReportState === 'failed' ? 400 : null, + }, + ], + }; +} + +const result = { + referrer: { id: 'referrer-1', email: 'referrer@example.com', name: 'Referrer' }, + referrals: [ + referralRow({ + referralId: 'qualified-referral', + refereeEmail: 'qualified@example.com', + paymentId: 'qualified-payment', + qualified: true, + disqualificationReason: null, + impactReportState: 'delivered', + }), + referralRow({ + referralId: 'disqualified-referral', + refereeEmail: 'disqualified@example.com', + paymentId: 'disqualified-payment', + qualified: false, + disqualificationReason: 'referral_self_referral', + impactReportState: 'failed', + }), + ], +}; + +describe('KiloclawReferralsInvestigationResults', () => { + it('renders qualified and disqualified referee diagnostics with reward and Impact state', () => { + const html = renderToStaticMarkup( + React.createElement(KiloclawReferralsInvestigationResults, { result }) + ); + + expect(html).toContain('referrer@example.com'); + expect(html).toContain('Qualified'); + expect(html).toContain('Disqualified'); + expect(html).toContain('referral_self_referral'); + expect(html).toContain('granted'); + expect(html).toContain('delivered, tracker 71659, order qualified-payment'); + expect(html).toContain('failed, tracker 71659, order disqualified-payment, HTTP 400'); + expect(html).toContain('May 1, 2026 to'); + expect(html).toContain('June 1, 2026'); + }); +}); diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx new file mode 100644 index 0000000000..6abb2b5521 --- /dev/null +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx @@ -0,0 +1,295 @@ +'use client'; + +import React, { useState } from 'react'; +import { Search } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useTRPC } from '@/lib/trpc/utils'; + +type InvestigationResult = { + referrer: { id: string; email: string | null; name: string | null }; + referrals: Array<{ + referral: { id: string; impactReferralId: string | null; createdAt: string }; + referee: { id: string; email: string | null; name: string | null }; + sourceTouch: { + id: string; + provider: string | null; + touchType: string | null; + landingPath: string | null; + rsCode: string | null; + imRef: string | null; + touchedAt: string | null; + expiresAt: string | null; + } | null; + conversion: { + id: string; + winningTouchType: string; + sourcePaymentId: string; + qualified: boolean; + disqualificationReason: string | null; + convertedAt: string; + } | null; + rewardDecisions: Array<{ + id: string; + beneficiaryUserId: string; + beneficiaryRole: string; + outcome: string; + reason: string | null; + monthsGranted: number; + createdAt: string; + }>; + rewards: Array<{ + id: string; + beneficiaryUserId: string; + beneficiaryRole: string; + status: string; + monthsGranted: number; + earnedAt: string; + appliedAt: string | null; + expiresAt: string | null; + reviewReason: string | null; + }>; + rewardApplications: Array<{ + id: string; + beneficiaryUserId: string; + subscriptionId: string | null; + previousRenewalBoundary: string; + newRenewalBoundary: string; + appliedAt: string; + }>; + impactReports: Array<{ + id: string; + state: string; + actionTrackerId: number; + orderId: string; + deliveredAt: string | null; + nextRetryAt: string | null; + responseStatusCode: number | null; + }>; + }>; +}; + +type ResultsProps = { + result: InvestigationResult; +}; + +function formatDate(value: string | null): string { + if (!value) return '—'; + return new Date(value).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function outcomeLabel(qualified: boolean): string { + return qualified ? 'Qualified' : 'Disqualified'; +} + +export function KiloclawReferralsInvestigationResults({ result }: ResultsProps) { + return ( +
+ + + Referrer + + Support investigation details for this KiloClaw referrer. + + + + + + + + + + + + Referees + + Includes qualified and disqualified referrals, reward decisions, applications, and + Impact report state. + + + + {result.referrals.length === 0 ? ( +
+ No referees found for this referrer. +
+ ) : ( + result.referrals.map(row => ) + )} +
+
+
+ ); +} + +function ReferralDiagnosticsRow({ row }: { row: InvestigationResult['referrals'][number] }) { + const conversion = row.conversion; + return ( +
+
+
+
{row.referee.email ?? row.referee.id}
+
{row.referee.id}
+
+ {conversion ? ( + + {outcomeLabel(conversion.qualified)} + + ) : null} +
+ +
+
+

+ Conversion +

+ {conversion ? ( +
+ + + + +
+ ) : ( +
No conversion recorded.
+ )} +
+ +
+

+ Reward decisions +

+ {row.rewardDecisions.length === 0 ? ( +
No reward decisions.
+ ) : ( +
+ {row.rewardDecisions.map(decision => ( +
+ {decision.beneficiaryRole}: {decision.outcome}, {decision.monthsGranted} month + {decision.monthsGranted === 1 ? '' : 's'} + {decision.reason ? ` (${decision.reason})` : ''} +
+ ))} +
+ )} +
+
+ +
+
+

+ Reward applications +

+ {row.rewardApplications.length === 0 ? ( +
No reward applications.
+ ) : ( + row.rewardApplications.map(application => ( +
+ {formatDate(application.previousRenewalBoundary)} to{' '} + {formatDate(application.newRenewalBoundary)} +
+ )) + )} +
+ +
+

+ Impact reports +

+ {row.impactReports.length === 0 ? ( +
No Impact reports.
+ ) : ( + row.impactReports.map(report => ( +
+ {report.state}, tracker {report.actionTrackerId}, order {report.orderId} + {report.responseStatusCode ? `, HTTP ${report.responseStatusCode}` : ''} +
+ )) + )} +
+
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function KiloclawReferralsInvestigation() { + const trpc = useTRPC(); + const [search, setSearch] = useState(''); + const [submittedSearch, setSubmittedSearch] = useState(null); + const query = useQuery( + trpc.admin.kiloclawReferrals.investigateReferrer.queryOptions( + { search: submittedSearch ?? '' }, + { enabled: submittedSearch !== null } + ) + ); + + return ( +
+ + + KiloClaw referral investigation + + Search by referrer user ID or email to inspect referee conversion and reward state. + + + +
{ + event.preventDefault(); + const trimmedSearch = search.trim(); + if (trimmedSearch) { + setSubmittedSearch(trimmedSearch); + } + }} + > +
+ + setSearch(event.target.value)} + placeholder="user_... or referrer@example.com" + /> +
+ +
+
+
+ + {query.isError ? ( + + + {query.error.message || 'Unable to load referral investigation.'} + + + ) : null} + {query.data ? : null} +
+ ); +} diff --git a/apps/web/src/app/admin/kiloclaw-referrals/page.tsx b/apps/web/src/app/admin/kiloclaw-referrals/page.tsx new file mode 100644 index 0000000000..24a09ff24b --- /dev/null +++ b/apps/web/src/app/admin/kiloclaw-referrals/page.tsx @@ -0,0 +1,5 @@ +import { KiloclawReferralsInvestigation } from '@/app/admin/components/KiloclawReferralsInvestigation'; + +export default function KiloclawReferralsPage() { + return ; +} diff --git a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts index 9ef29eff73..1bd18fda8a 100644 --- a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts +++ b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts @@ -8,10 +8,36 @@ jest.mock('@/lib/affiliate-events', () => ({ dispatchQueuedAffiliateEvents: jest.fn(), })); +jest.mock('@/lib/impact-referral', () => ({ + dispatchQueuedImpactAdvocateRegistrationAttempts: jest.fn(), +})); + +jest.mock('@/lib/kiloclaw-referrals', () => ({ + dispatchQueuedImpactAdvocateRewardRedemptions: jest.fn(), + dispatchQueuedImpactConversionReports: jest.fn(), + processQueuedKiloClawReferralRewards: jest.fn(), +})); + import { dispatchQueuedAffiliateEvents } from '@/lib/affiliate-events'; +import { dispatchQueuedImpactAdvocateRegistrationAttempts } from '@/lib/impact-referral'; +import { + dispatchQueuedImpactAdvocateRewardRedemptions, + dispatchQueuedImpactConversionReports, + processQueuedKiloClawReferralRewards, +} from '@/lib/kiloclaw-referrals'; import { GET } from './route'; const mockDispatchQueuedAffiliateEvents = jest.mocked(dispatchQueuedAffiliateEvents); +const mockDispatchQueuedImpactAdvocateRegistrationAttempts = jest.mocked( + dispatchQueuedImpactAdvocateRegistrationAttempts +); +const mockDispatchQueuedImpactConversionReports = jest.mocked( + dispatchQueuedImpactConversionReports +); +const mockDispatchQueuedImpactAdvocateRewardRedemptions = jest.mocked( + dispatchQueuedImpactAdvocateRewardRedemptions +); +const mockProcessQueuedKiloClawReferralRewards = jest.mocked(processQueuedKiloClawReferralRewards); describe('GET /api/cron/dispatch-affiliate-events', () => { beforeEach(() => { @@ -28,6 +54,10 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { expect(response.status).toBe(401); await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); expect(mockDispatchQueuedAffiliateEvents).not.toHaveBeenCalled(); + expect(mockDispatchQueuedImpactAdvocateRegistrationAttempts).not.toHaveBeenCalled(); + expect(mockDispatchQueuedImpactConversionReports).not.toHaveBeenCalled(); + expect(mockProcessQueuedKiloClawReferralRewards).not.toHaveBeenCalled(); + expect(mockDispatchQueuedImpactAdvocateRewardRedemptions).not.toHaveBeenCalled(); }); it('dispatches queued affiliate events when authorized', async () => { @@ -39,6 +69,31 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { failed: 0, unblocked: 1, }); + mockDispatchQueuedImpactAdvocateRegistrationAttempts.mockResolvedValue({ + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }); + mockDispatchQueuedImpactConversionReports.mockResolvedValue({ + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }); + mockProcessQueuedKiloClawReferralRewards.mockResolvedValue({ + claimed: 3, + applied: 2, + expired: 1, + pending: 0, + failed: 0, + }); + mockDispatchQueuedImpactAdvocateRewardRedemptions.mockResolvedValue({ + claimed: 2, + redeemed: 2, + retried: 0, + failed: 0, + }); const response = await GET( new NextRequest('http://localhost:3000/api/cron/dispatch-affiliate-events', { @@ -51,16 +106,47 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { expect(response.status).toBe(200); expect(mockDispatchQueuedAffiliateEvents).toHaveBeenCalledTimes(1); + expect(mockDispatchQueuedImpactAdvocateRegistrationAttempts).toHaveBeenCalledTimes(1); + expect(mockDispatchQueuedImpactConversionReports).toHaveBeenCalledTimes(1); + expect(mockProcessQueuedKiloClawReferralRewards).toHaveBeenCalledTimes(1); + expect(mockDispatchQueuedImpactAdvocateRewardRedemptions).toHaveBeenCalledTimes(1); await expect(response.json()).resolves.toEqual( expect.objectContaining({ success: true, summary: { - reclaimed: 1, - claimed: 3, - delivered: 2, - retried: 1, - failed: 0, - unblocked: 1, + affiliateEvents: { + reclaimed: 1, + claimed: 3, + delivered: 2, + retried: 1, + failed: 0, + unblocked: 1, + }, + impactAdvocateRegistrations: { + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }, + impactConversionReports: { + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }, + referralRewards: { + claimed: 3, + applied: 2, + expired: 1, + pending: 0, + failed: 0, + }, + impactAdvocateRewardRedemptions: { + claimed: 2, + redeemed: 2, + retried: 0, + failed: 0, + }, }, timestamp: expect.any(String), }) diff --git a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts index cb5e981962..723cca30ee 100644 --- a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts +++ b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts @@ -2,6 +2,12 @@ import { NextResponse } from 'next/server'; import { CRON_SECRET } from '@/lib/config.server'; import { dispatchQueuedAffiliateEvents } from '@/lib/affiliate-events'; +import { dispatchQueuedImpactAdvocateRegistrationAttempts } from '@/lib/impact-referral'; +import { + dispatchQueuedImpactAdvocateRewardRedemptions, + dispatchQueuedImpactConversionReports, + processQueuedKiloClawReferralRewards, +} from '@/lib/kiloclaw-referrals'; import { sentryLogger } from '@/lib/utils.server'; if (!CRON_SECRET) { @@ -22,12 +28,30 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const summary = await dispatchQueuedAffiliateEvents(); + const [ + affiliateSummary, + impactAdvocateRegistrationSummary, + impactConversionSummary, + referralRewardSummary, + impactAdvocateRewardRedemptionSummary, + ] = await Promise.all([ + dispatchQueuedAffiliateEvents(), + dispatchQueuedImpactAdvocateRegistrationAttempts(), + dispatchQueuedImpactConversionReports(), + processQueuedKiloClawReferralRewards(), + dispatchQueuedImpactAdvocateRewardRedemptions(), + ]); return NextResponse.json( { success: true, - summary, + summary: { + affiliateEvents: affiliateSummary, + impactAdvocateRegistrations: impactAdvocateRegistrationSummary, + impactConversionReports: impactConversionSummary, + referralRewards: referralRewardSummary, + impactAdvocateRewardRedemptions: impactAdvocateRewardRedemptionSummary, + }, timestamp: new Date().toISOString(), }, { status: 200 } diff --git a/apps/web/src/app/api/impact-advocate/token/route.ts b/apps/web/src/app/api/impact-advocate/token/route.ts new file mode 100644 index 0000000000..93dd129b4e --- /dev/null +++ b/apps/web/src/app/api/impact-advocate/token/route.ts @@ -0,0 +1,75 @@ +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; + +import { referral_codes } from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; +import { getUserFromAuth } from '@/lib/user.server'; +import { + getImpactAdvocateWidgetId, + issueImpactAdvocateVerifiedAccessToken, +} from '@/lib/impact-advocate'; +import { + countryCodeFromHeaders, + localeFromHeaders, + queueImpactAdvocateSelfRegistration, +} from '@/lib/impact-referral'; + +/** + * Internal Kilo referral code (kept for legacy/internal attribution flows in + * `referral_codes`). This is intentionally NOT linked to + * `impact_advocate_participants.opaque_referral_identifier` anymore — that + * column is now reserved for the SaaSquatch-issued referral code so the + * conversion lifecycle's referrer-resolution lookup actually works. + */ +async function ensureInternalReferralCode(userId: string): Promise { + await db + .insert(referral_codes) + .values({ kilo_user_id: userId, code: crypto.randomUUID() }) + .onConflictDoNothing({ target: [referral_codes.kilo_user_id] }); +} + +export async function GET() { + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) { + return authFailedResponse; + } + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = issueImpactAdvocateVerifiedAccessToken(user); + if (!token) { + return NextResponse.json({ error: 'Impact Advocate is not configured' }, { status: 503 }); + } + + try { + await ensureInternalReferralCode(user.id); + + // Mirror the user into SaaSquatch as an advocate so they become + // discoverable when their referees convert. The dispatcher reads the + // SaaSquatch-issued code out of the response and persists it as + // `participants.opaque_referral_identifier`. Idempotent across repeat + // page loads via dedupe key. + const requestHeaders = await headers(); + await queueImpactAdvocateSelfRegistration({ + user, + locale: localeFromHeaders(requestHeaders), + countryCode: countryCodeFromHeaders(requestHeaders), + }); + } catch (error) { + console.error('[impact-advocate-token] failed to prepare referral sharing identity', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Referral sharing is temporarily unavailable' }, + { status: 503 } + ); + } + + return NextResponse.json({ + token, + widgetId: getImpactAdvocateWidgetId(), + }); +} diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts index cbe3ebc3ae..3c54525a55 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; import { send as sendEmail } from '@/lib/email'; import { maybePerformAutoTopUp } from '@/lib/autoTopUp'; import { enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; jest.mock('@/lib/config.server', () => ({ INTERNAL_API_SECRET: 'internal-secret', @@ -35,6 +36,10 @@ jest.mock('@/lib/affiliate-events', () => ({ enqueueAffiliateEventForUser: jest.fn(), })); +jest.mock('@/lib/kiloclaw-referrals', () => ({ + processPersonalKiloClawPaidConversion: jest.fn(), +})); + jest.mock('@/lib/kiloclaw/credit-billing', () => ({ projectPendingKiloPassBonusMicrodollars: jest.fn(), })); @@ -48,6 +53,9 @@ import { POST } from './route'; const mockSendEmail = jest.mocked(sendEmail); const mockMaybePerformAutoTopUp = jest.mocked(maybePerformAutoTopUp); const mockEnqueueAffiliateEventForUser = jest.mocked(enqueueAffiliateEventForUser); +const mockProcessPersonalKiloClawPaidConversion = jest.mocked( + processPersonalKiloClawPaidConversion +); type ConsoleSpy = jest.SpiedFunction | jest.SpiedFunction; @@ -82,6 +90,12 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { mockSendEmail.mockResolvedValue({ sent: true }); mockMaybePerformAutoTopUp.mockResolvedValue(undefined); + mockProcessPersonalKiloClawPaidConversion.mockResolvedValue({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: 'conversion_123', + disqualificationReason: null, + }); }); it('logs started and completed side effects with billing correlation and no email recipient', async () => { @@ -225,4 +239,97 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { promoCode: undefined, }); }); + + it('processes paid conversions without enqueueing affiliate sales when referrals win attribution', async () => { + mockProcessPersonalKiloClawPaidConversion.mockResolvedValueOnce({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_impact', + disqualificationReason: null, + }); + + const response = await POST( + createRequest({ + action: 'process_paid_conversion', + input: { + userId: 'user-123', + dedupeKey: 'affiliate:impact:sale:period-123', + eventDateIso: '2026-04-09T10:00:00.000Z', + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + }, + }) + ); + + expect(response.status).toBe(200); + expect(mockProcessPersonalKiloClawPaidConversion).toHaveBeenCalledWith({ + userId: 'user-123', + sourcePaymentId: 'period-123', + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T10:00:00.000Z'), + }); + expect(mockEnqueueAffiliateEventForUser).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + affiliateSaleEnqueued: false, + winningTouchType: 'referral', + conversionId: 'conversion_impact', + disqualificationReason: null, + }); + }); + + it('enqueues affiliate sales when paid conversion attribution returns an affiliate winner', async () => { + mockProcessPersonalKiloClawPaidConversion.mockResolvedValueOnce({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: 'conversion_affiliate', + disqualificationReason: 'referral_affiliate_won', + }); + + const response = await POST( + createRequest({ + action: 'process_paid_conversion', + input: { + userId: 'user-123', + dedupeKey: 'affiliate:impact:sale:period-123', + eventDateIso: '2026-04-09T10:00:00.000Z', + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + }, + }) + ); + + expect(response.status).toBe(200); + expect(mockEnqueueAffiliateEventForUser).toHaveBeenCalledWith({ + userId: 'user-123', + provider: 'impact', + eventType: 'sale', + dedupeKey: 'affiliate:impact:sale:period-123', + eventDate: new Date('2026-04-09T10:00:00.000Z'), + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + }); + await expect(response.json()).resolves.toEqual({ + affiliateSaleEnqueued: true, + winningTouchType: 'affiliate', + conversionId: 'conversion_affiliate', + disqualificationReason: 'referral_affiliate_won', + }); + }); }); diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts index 15ed3815bb..7674558003 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts @@ -15,6 +15,8 @@ import { ensureAutoIntroSchedule } from '@/lib/kiloclaw/stripe-handlers'; import { isIntroPriceId } from '@/lib/kiloclaw/stripe-price-ids.server'; import { client as stripe } from '@/lib/stripe-client'; import { enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { projectPendingKiloPassBonusMicrodollars } from '@/lib/kiloclaw/credit-billing'; import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; @@ -130,6 +132,20 @@ const BodySchema = z.discriminatedUnion('action', [ promoCode: z.string().min(1).optional(), }), }), + z.object({ + action: z.literal('process_paid_conversion'), + input: z.object({ + userId: z.string().min(1), + dedupeKey: z.string().min(1), + eventDateIso: z.string().datetime(), + orderId: z.string().min(1), + amount: z.number().nonnegative(), + currencyCode: z.string().min(1), + itemCategory: z.string().min(1), + itemName: z.string().min(1), + itemSku: z.string().min(1).optional(), + }), + }), z.object({ action: z.literal('project_pending_kilo_pass_bonus'), input: z.object({ @@ -169,6 +185,8 @@ function getActionLogFields(body: z.infer): { }; case 'enqueue_affiliate_event': return { userId: body.input.userId }; + case 'process_paid_conversion': + return { userId: body.input.userId }; case 'project_pending_kilo_pass_bonus': return { userId: body.input.userId }; case 'issue_kilo_pass_bonus_from_usage_threshold': @@ -220,6 +238,12 @@ export async function POST(request: NextRequest) { | { ok: true } | { repaired: boolean } | { enqueued: boolean } + | { + affiliateSaleEnqueued: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; + } | { projectedBonusMicrodollars: number }; switch (parsed.data.action) { @@ -249,6 +273,16 @@ export async function POST(request: NextRequest) { } case 'enqueue_affiliate_event': + logImpactReferralDebug('KiloClaw billing side effect enqueueing affiliate event', { + userId: parsed.data.input.userId, + provider: parsed.data.input.provider, + eventType: parsed.data.input.eventType, + dedupeKey: parsed.data.input.dedupeKey, + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + }); await enqueueAffiliateEventForUser({ userId: parsed.data.input.userId, provider: parsed.data.input.provider, @@ -266,6 +300,61 @@ export async function POST(request: NextRequest) { payload = { enqueued: true }; break; + case 'process_paid_conversion': { + logImpactReferralDebug('KiloClaw billing side effect processing paid conversion', { + userId: parsed.data.input.userId, + dedupeKey: parsed.data.input.dedupeKey, + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + }); + const disposition = await processPersonalKiloClawPaidConversion({ + userId: parsed.data.input.userId, + sourcePaymentId: parsed.data.input.orderId, + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + itemName: parsed.data.input.itemName, + itemSku: parsed.data.input.itemSku, + convertedAt: new Date(parsed.data.input.eventDateIso), + }); + + logImpactReferralDebug('KiloClaw billing side effect paid conversion disposition', { + userId: parsed.data.input.userId, + orderId: parsed.data.input.orderId, + shouldEnqueueAffiliateSale: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + }); + + if (disposition.shouldEnqueueAffiliateSale) { + await enqueueAffiliateEventForUser({ + userId: parsed.data.input.userId, + provider: 'impact', + eventType: 'sale', + dedupeKey: parsed.data.input.dedupeKey, + eventDate: new Date(parsed.data.input.eventDateIso), + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + itemName: parsed.data.input.itemName, + itemSku: parsed.data.input.itemSku, + }); + } + + payload = { + affiliateSaleEnqueued: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + }; + break; + } + case 'project_pending_kilo_pass_bonus': payload = { projectedBonusMicrodollars: await projectPendingKiloPassBonusMicrodollars({ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b68d1e9ab6..2943ba3092 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import './globals.css'; import { PostHogProvider } from '../components/PostHogProvider'; import { Providers } from '../components/Providers'; import { DataLayerProvider } from '../components/DataLayerProvider'; +import { ImpactIdentify } from '@/components/ImpactIdentify'; import { APP_URL } from '@/lib/constants'; const inter = Inter({ @@ -108,6 +109,7 @@ export default function RootLayout({ + {children} diff --git a/apps/web/src/app/users/after-sign-in/route.tsx b/apps/web/src/app/users/after-sign-in/route.tsx index 04b7e9366d..65edb585e6 100644 --- a/apps/web/src/app/users/after-sign-in/route.tsx +++ b/apps/web/src/app/users/after-sign-in/route.tsx @@ -4,11 +4,23 @@ import { maybeInterceptWithSurvey } from '@/lib/survey-redirect'; import PostHogClient from '@/lib/posthog'; import { getAffiliateAttribution } from '@/lib/affiliate-attribution'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/affiliate-events'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { IMPACT_APP_TRACKED_CLICK_ID_COOKIE, IMPACT_CLICK_ID_COOKIE, resolveImpactAffiliateTrackingId, } from '@/lib/impact-affiliate-utils'; +import { + countryCodeFromHeaders, + localeFromHeaders, + queueImpactAdvocateParticipantRegistration, + recordImpactAffiliateTouch, + recordImpactReferralTouch, +} from '@/lib/impact-referral'; +import { + parseImpactAffiliateTouchFromUrl, + parseImpactReferralTouchFromUrl, +} from '@/lib/impact-referral-utils'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { APP_URL } from '@/lib/constants'; @@ -20,6 +32,42 @@ import { isCreditCampaignCallback, lookupCampaignBySlug } from '@/lib/credit-cam * the entry point is generic (e.g. /get-started, /profile) so we leave the * property unset rather than guessing. */ +const TRACKING_REDIRECT_PARAMS = [ + 'source', + 'im_ref', + '_saasquatch', + 'rsCode', + 'rsShareMedium', + 'rsEngagementMedium', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', +] as const; + +function signInPathWithPreservedTrackingParams(url: URL): string { + const params = new URLSearchParams(); + const callbackPath = url.searchParams.get('callbackPath'); + + if (callbackPath && isValidCallbackPath(callbackPath)) { + params.set('callbackPath', callbackPath); + } + + if (url.searchParams.get('signup') === 'true') { + params.set('signup', 'true'); + } + + for (const param of TRACKING_REDIRECT_PARAMS) { + const value = url.searchParams.get(param)?.trim(); + if (value) { + params.set(param, value); + } + } + + return `/users/sign_in${params.size > 0 ? `?${params.toString()}` : ''}`; +} + async function resolveSignupProduct( callbackPath: string | null, hasSource: boolean @@ -60,7 +108,7 @@ export async function GET(request: NextRequest) { let responsePath: string; if (!user) { - responsePath = '/users/sign_in'; + responsePath = signInPathWithPreservedTrackingParams(url); } else if (user.blocked_reason) { responsePath = '/account-blocked'; } else { @@ -108,27 +156,124 @@ export async function GET(request: NextRequest) { } } + const referralTouch = parseImpactReferralTouchFromUrl(url); + const urlImRefParam = url.searchParams.get('im_ref')?.trim() || null; + const ignoreUrlImRefForReferralTouch = Boolean( + referralTouch?.opaqueTrackingValue && urlImRefParam + ); + // Resolve the Impact click ID: prefer the explicit URL param, fall back to // the shared parent-domain cookie written by kilo.ai. This is intentionally // separate from Impact's native IR_ UTT cookie. const { affiliateTrackingId, impactCookieValue } = resolveImpactAffiliateTrackingId({ - imRefParam: url.searchParams.get('im_ref')?.trim() || null, + imRefParam: urlImRefParam, sharedImpactCookieValue: request.cookies.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null, appTrackedImpactCookieValue: request.cookies.get(IMPACT_APP_TRACKED_CLICK_ID_COOKIE)?.value?.trim() || null, + ignoreImRefParam: ignoreUrlImRefForReferralTouch, + }); + + const affiliateTouch = affiliateTrackingId + ? parseImpactAffiliateTouchFromUrl(url, affiliateTrackingId) + : null; + + logImpactReferralDebug('After sign-in resolved Impact tracking context', { + userId: user?.id ?? null, + responsePath, + affiliateTrackingIdPresent: Boolean(affiliateTrackingId?.trim()), + impactCookieValuePresent: Boolean(impactCookieValue?.trim()), + affiliateTouchPresent: Boolean(affiliateTouch), + referralTouchPresent: Boolean(referralTouch), + referralCookieValuePresent: Boolean(referralTouch?.opaqueTrackingValue), + ignoredUrlImRefForReferralTouch: ignoreUrlImRefForReferralTouch, + callbackPath: url.searchParams.get('callbackPath') ?? null, }); + if (user && affiliateTouch) { + try { + logImpactReferralDebug('After sign-in recording Impact affiliate touch', { + userId: user.id, + landingPath: affiliateTouch.landingPath, + trackingValueLength: affiliateTouch.trackingValueLength, + isTrackingValueAccepted: affiliateTouch.isTrackingValueAccepted, + }); + await recordImpactAffiliateTouch({ + userId: user.id, + touch: affiliateTouch, + }); + } catch (error) { + console.error('[after-sign-in] failed to record affiliate touch', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (user && referralTouch) { + try { + logImpactReferralDebug('After sign-in recording Impact Advocate referral touch', { + userId: user.id, + landingPath: referralTouch.landingPath, + rsCodePresent: Boolean(referralTouch.rsCode?.trim()), + trackingValueLength: referralTouch.trackingValueLength, + isTrackingValueAccepted: referralTouch.isTrackingValueAccepted, + }); + await recordImpactReferralTouch({ + userId: user.id, + touch: referralTouch, + }); + } catch (error) { + console.error('[after-sign-in] failed to record referral touch', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + logImpactReferralDebug('After sign-in queueing Impact Advocate participant registration', { + userId: user.id, + landingPath: referralTouch.landingPath, + localePresent: Boolean(localeFromHeaders(request.headers)?.trim()), + countryCode: countryCodeFromHeaders(request.headers), + }); + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch, + locale: localeFromHeaders(request.headers), + countryCode: countryCodeFromHeaders(request.headers), + }); + } catch (error) { + console.error('[after-sign-in] failed to enqueue Impact Advocate registration', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (user && affiliateTrackingId) { const existingAttribution = await getAffiliateAttribution(user.id, 'impact'); + logImpactReferralDebug('After sign-in checked Impact affiliate attribution row', { + userId: user.id, + existingAttributionPresent: Boolean(existingAttribution), + trackingIdLength: affiliateTrackingId.length, + }); + if (!existingAttribution) { - await recordAffiliateAttributionAndQueueParentEvent({ - userId: user.id, - provider: 'impact', - trackingId: affiliateTrackingId, - customerEmail: user.google_user_email, - eventDate: new Date(), - }); + try { + await recordAffiliateAttributionAndQueueParentEvent({ + userId: user.id, + provider: 'impact', + trackingId: affiliateTrackingId, + customerEmail: user.google_user_email, + eventDate: new Date(), + }); + } catch (error) { + console.error('[after-sign-in] failed to persist affiliate attribution', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } } } @@ -140,6 +285,10 @@ export async function GET(request: NextRequest) { // hit would burn the marker and suppress the fallback on the next real // sign-in. if (user && impactCookieValue) { + logImpactReferralDebug('After sign-in setting app-tracked Impact click cookie marker', { + userId: user.id, + impactCookieValueLength: impactCookieValue.length, + }); response.cookies.set(IMPACT_APP_TRACKED_CLICK_ID_COOKIE, impactCookieValue, { path: '/', httpOnly: true, diff --git a/apps/web/src/components/ImpactIdentify.tsx b/apps/web/src/components/ImpactIdentify.tsx index 3f2a2da30c..4f5f71c6b3 100644 --- a/apps/web/src/components/ImpactIdentify.tsx +++ b/apps/web/src/components/ImpactIdentify.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { useUser } from '@/hooks/useUser'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; +import { IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY } from '@/lib/impact-referral-utils'; async function sha1Hex(value: string): Promise { const normalized = value.trim().toLowerCase(); @@ -12,32 +14,72 @@ async function sha1Hex(value: string): Promise { .join(''); } +function getStableAnonymousProfileId(): string { + const existing = window.localStorage.getItem(IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY)?.trim(); + if (existing) { + return existing; + } + + const generated = `kilo-anon:${crypto.randomUUID()}`; + window.localStorage.setItem(IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY, generated); + return generated; +} + export function ImpactIdentify() { const { data: user } = useUser(); useEffect(() => { - if (!user || typeof window.ire !== 'function') return; - let cancelled = false; + let retryTimeout: ReturnType | null = null; + + const runIdentify = async (retriesRemaining: number): Promise => { + if (cancelled) return; + + if (typeof window.ire !== 'function') { + if (retriesRemaining <= 0) { + logImpactReferralDebug('Impact UTT identify skipped; window.ire unavailable', { + userId: user?.id ?? null, + }); + return; + } + + retryTimeout = setTimeout(() => { + void runIdentify(retriesRemaining - 1); + }, 250); + return; + } - void sha1Hex(user.google_user_email) - .then(hashedEmail => { - if (cancelled || typeof window.ire !== 'function') return; - - window.ire('identify', { - customerId: user.id, - customerEmail: hashedEmail, - customProfileId: '', - }); - }) - .catch(error => { - console.error('ImpactIdentify failed', error); + const customProfileId = user?.id ? `kilo-user:${user.id}` : getStableAnonymousProfileId(); + const customerId = user?.id ?? ''; + const customerEmail = user ? await sha1Hex(user.google_user_email) : ''; + + if (cancelled || typeof window.ire !== 'function') return; + + logImpactReferralDebug('Calling Impact UTT identify', { + userId: user?.id ?? null, + customerIdPresent: Boolean(customerId), + customerEmailHashPresent: Boolean(customerEmail), + customProfileIdPresent: Boolean(customProfileId), + }); + + window.ire('identify', { + customerId, + customerEmail, + customProfileId, }); + }; + + void runIdentify(10).catch(error => { + console.error('ImpactIdentify failed', error); + }); return () => { cancelled = true; + if (retryTimeout) { + clearTimeout(retryTimeout); + } }; - }, [user]); + }, [user?.google_user_email, user?.id]); return null; } diff --git a/apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx b/apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx new file mode 100644 index 0000000000..407ee68595 --- /dev/null +++ b/apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { createElement, useEffect, useState } from 'react'; + +type WidgetState = + | { status: 'loading' } + | { status: 'ready'; token: string; widgetId: string } + | { status: 'unavailable'; message: string }; + +function renderWidgetContent(state: WidgetState) { + switch (state.status) { + case 'loading': + return
Loading referral sharing…
; + case 'unavailable': + return
{state.message}
; + case 'ready': + return ( +
+ {createElement( + 'impact-embed', + { + widget: state.widgetId, + className: 'block min-h-52 w-full', + }, +
Loading referral widget…
+ )} +
+ ); + } +} + +export function ImpactAdvocateReferralWidget() { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + delete window.impactToken; + + const loadWidgetToken = async () => { + try { + const response = await fetch('/api/impact-advocate/token', { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + }, + }); + + const payload = (await response.json().catch(() => null)) as { + token?: string; + widgetId?: string; + error?: string; + } | null; + + if (cancelled) { + return; + } + + if (!response.ok || !payload?.token || !payload.widgetId) { + delete window.impactToken; + setState({ + status: 'unavailable', + message: + payload?.error ?? + (response.status === 503 + ? 'Referral sharing is not configured in this environment.' + : 'Referral sharing is temporarily unavailable.'), + }); + return; + } + + window.impactToken = payload.token; + setState({ + status: 'ready', + token: payload.token, + widgetId: payload.widgetId, + }); + } catch (error) { + if (cancelled) { + return; + } + + delete window.impactToken; + setState({ + status: 'unavailable', + message: error instanceof Error ? error.message : 'Failed to load referral sharing.', + }); + } + }; + + void loadWidgetToken(); + + return () => { + cancelled = true; + delete window.impactToken; + }; + }, []); + + return
{renderWidgetContent(state)}
; +} diff --git a/apps/web/src/components/subscriptions/DetailRow.tsx b/apps/web/src/components/subscriptions/DetailRow.tsx new file mode 100644 index 0000000000..ff348f67bf --- /dev/null +++ b/apps/web/src/components/subscriptions/DetailRow.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +type DetailRowProps = { + label: ReactNode; + value: ReactNode; + className?: string; + /** Apply tabular-nums to the value (use for currency, dates, counts). */ + numeric?: boolean; +}; + +/** + * Standard label-over-value row used across subscription detail surfaces. + * Pairs a muted label with a foreground value, optionally tabular for numbers. + */ +export function DetailRow({ label, value, className, numeric = false }: DetailRowProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx index 6c14c33e98..51908d7e66 100644 --- a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx +++ b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx @@ -1,16 +1,20 @@ 'use client'; import { useCallback, useState, type ReactNode } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { ArrowRight } from 'lucide-react'; import { toast } from 'sonner'; import KiloCrabIcon from '@/components/KiloCrabIcon'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { useRawTRPCClient, useTRPC } from '@/lib/trpc/utils'; import { DetailPageHeader } from '@/components/subscriptions/DetailPageHeader'; +import { DetailRow } from '@/components/subscriptions/DetailRow'; import { StripePortalLink } from '@/components/subscriptions/StripePortalLink'; import { BillingHistoryTable } from '@/components/subscriptions/BillingHistoryTable'; +import { ReferralRewardsSummary } from '@/app/(app)/claw/components/billing/ReferralRewardsSummary'; +import { useInvalidateKiloClawBilling } from '@/components/subscriptions/kiloclaw/useKiloClawBillingQueries'; import { AlertDialog, AlertDialogAction, @@ -53,7 +57,7 @@ type ConfirmationDetails = { export function KiloClawDetail({ instanceId }: { instanceId: string }) { const trpc = useTRPC(); const trpcClient = useRawTRPCClient(); - const queryClient = useQueryClient(); + const refreshData = useInvalidateKiloClawBilling(instanceId); const [confirmationAction, setConfirmationAction] = useState(null); const [pendingConfirmationAction, setPendingConfirmationAction] = @@ -72,20 +76,6 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { resetKey: instanceId, }); - async function refreshData() { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), - }), - ]); - } - async function runAction(action: () => Promise, successMessage: string) { await action(); toast.success(successMessage); @@ -235,9 +225,9 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { }); } - const primaryDetailRows: Array<{ label: string; value: string }> = [ + const primaryDetailRows: Array<{ label: string; value: string; numeric?: boolean }> = [ { label: 'Plan', value: capitalize(subscription.plan) }, - { label: 'Price', value: formatKiloclawPrice(subscription.plan) }, + { label: 'Price', value: formatKiloclawPrice(subscription.plan), numeric: true }, { label: 'Payment source', value: formatPaymentSummary({ @@ -248,25 +238,29 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { { label: 'Next renewal', value: nextRenewalLabel === 'At your next renewal' ? '—' : nextRenewalLabel, + numeric: true, }, ]; - const secondaryDetailRows: Array<{ label: string; value: string }> = [ + type SecondaryRow = { label: string; value: string; numeric?: boolean }; + const secondaryRowSource: Array = [ subscription.commitEndsAt - ? { label: 'Commit ends', value: formatDateLabel(subscription.commitEndsAt) } + ? { label: 'Commit ends', value: formatDateLabel(subscription.commitEndsAt), numeric: true } : null, subscription.status === 'trialing' && subscription.trialEndsAt - ? { label: 'Trial ends', value: formatDateLabel(subscription.trialEndsAt) } + ? { label: 'Trial ends', value: formatDateLabel(subscription.trialEndsAt), numeric: true } : null, subscription.suspendedAt - ? { label: 'Suspended at', value: formatDateLabel(subscription.suspendedAt) } + ? { label: 'Suspended at', value: formatDateLabel(subscription.suspendedAt), numeric: true } : null, subscription.destructionDeadline ? { label: 'Destruction deadline', value: formatDateLabel(subscription.destructionDeadline), + numeric: true, } : null, - ].filter((row): row is { label: string; value: string } => row !== null); + ]; + const secondaryDetailRows = secondaryRowSource.filter((row): row is SecondaryRow => row !== null); return (
@@ -293,20 +287,30 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) {
{primaryDetailRows.map(row => ( - + ))}
{statusNote ? ( -
- {statusNote} -
+ + {statusNote} + ) : null} {secondaryDetailRows.length > 0 ? (
{secondaryDetailRows.map(row => ( - + ))}
) : null} @@ -319,26 +323,24 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { {subscription.plan !== 'trial' ? ( hasUserRequestedSwitch ? ( ) : ( ) ) : null} {subscription.cancelAtPeriodEnd ? ( - + ) : ( )} @@ -362,6 +364,8 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) {
)} + + Billing history @@ -399,6 +403,7 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { variant={confirmationDetails?.confirmVariant ?? 'default'} onClick={confirmSubscriptionAction} disabled={pendingConfirmationAction !== null} + aria-busy={pendingConfirmationAction !== null} > {pendingConfirmationAction !== null ? confirmationDetails?.pendingLabel @@ -419,12 +424,3 @@ function ConfirmationDetailRow({ label, value }: { label: string; value: string
); } - -function DetailRow({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts b/apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts new file mode 100644 index 0000000000..e933d83330 --- /dev/null +++ b/apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/lib/trpc/utils'; + +/** + * Returns a stable callback that invalidates every query keyed off a KiloClaw + * instance's billing/subscription state. Both the `/claw` dashboard card and + * the `/subscriptions/kiloclaw/:id` detail page need the exact same set of + * invalidations after a state-changing mutation; centralising them here keeps + * the two surfaces from drifting. + */ +export function useInvalidateKiloClawBilling(instanceId: string | null) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useCallback(async () => { + if (!instanceId) return; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getActivePersonalBillingStatus.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getPersonalBillingSummary.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), + }), + ]); + }, [queryClient, trpc, instanceId]); +} diff --git a/apps/web/src/db/empty-database.ts b/apps/web/src/db/empty-database.ts index 1961af2846..586c0c84d7 100644 --- a/apps/web/src/db/empty-database.ts +++ b/apps/web/src/db/empty-database.ts @@ -2,36 +2,40 @@ import '../lib/load-env'; import { sql } from 'drizzle-orm'; import { db } from '../lib/drizzle'; +function quotePostgresIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + async function main() { - console.log('Emptying database (drop all tables+views)...'); + console.log('Resetting database (drop and recreate app schemas)...'); - const { rows: tables } = await db.execute( - sql`SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('information_schema', 'pg_catalog')` - ); + const { rows } = await db.execute(sql` + SELECT nspname + FROM pg_namespace + WHERE nspname NOT LIKE 'pg_%' + AND nspname <> 'information_schema' + `); - for (const { schemaname, tablename } of tables) { - if (typeof schemaname === 'string' && typeof tablename === 'string') { - console.log(`Dropping table ${schemaname}.${tablename}...`); - await db.execute(sql.raw(`DROP TABLE "${schemaname}"."${tablename}" CASCADE`)); + for (const row of rows) { + if (typeof row.nspname !== 'string') { + continue; } - } - const { rows: views } = await db.execute( - sql`SELECT schemaname, viewname FROM pg_views WHERE schemaname NOT IN ('information_schema', 'pg_catalog')` - ); - - for (const { schemaname, viewname } of views) { - if (typeof schemaname === 'string' && typeof viewname === 'string') { - console.log(`Dropping view ${schemaname}.${viewname}...`); - await db.execute(sql.raw(`DROP VIEW "${schemaname}"."${viewname}" CASCADE`)); - } + console.log(`Dropping schema ${row.nspname}...`); + await db.execute( + sql.raw(`DROP SCHEMA IF EXISTS ${quotePostgresIdentifier(row.nspname)} CASCADE`) + ); } - console.log('Database emptied! You should run "pnpm drizzle migrate" to recreate our schema.'); + await db.execute(sql.raw('CREATE SCHEMA "public"')); + + console.log( + 'Database reset to empty app schemas. Run "pnpm drizzle migrate" to recreate our schema.' + ); process.exit(0); } main().catch(error => { - console.error('Database emptying failed:', error); + console.error('Database reset failed:', error); process.exit(1); }); diff --git a/apps/web/src/lib/affiliate-events.ts b/apps/web/src/lib/affiliate-events.ts index 6fdd810f41..3a7b439bab 100644 --- a/apps/web/src/lib/affiliate-events.ts +++ b/apps/web/src/lib/affiliate-events.ts @@ -16,6 +16,7 @@ import { reverseImpactAction, sendImpactConversionPayload, } from '@/lib/impact'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { sentryLogger } from '@/lib/utils.server'; import { kilocode_users, @@ -792,6 +793,10 @@ export async function findOrCreateParentEvent( logInfo(inserted ? 'Enqueued affiliate parent event' : 'Affiliate parent event already exists', { ...buildAffiliateEventLogFields(event), }); + logImpactReferralDebug( + inserted ? 'Enqueued affiliate parent event' : 'Affiliate parent event already exists', + buildAffiliateEventLogFields(event) + ); return event; } @@ -801,11 +806,22 @@ export async function recordAffiliateAttributionAndQueueParentEvent( const database = getDatabaseClient(params.database); const trackingId = params.trackingId.trim(); + logImpactReferralDebug('Recording affiliate attribution and queueing parent event', { + userId: params.userId, + affiliateProvider: params.provider, + trackingIdPresent: Boolean(trackingId), + trackingIdLength: trackingId.length, + }); + if (!trackingId) { logWarning('Skipped affiliate attribution enqueue because tracking ID was empty', { user_id: params.userId, affiliate_provider: params.provider, }); + logImpactReferralDebug('Skipped affiliate attribution enqueue because tracking ID was empty', { + userId: params.userId, + affiliateProvider: params.provider, + }); return null; } @@ -847,6 +863,12 @@ export async function enqueueAffiliateEventForUser( affiliate_event_type: params.eventType, affiliate_dedupe_key: params.dedupeKey, }); + logImpactReferralDebug('Skipped affiliate child enqueue because user was missing', { + userId: params.userId, + affiliateProvider: params.provider, + affiliateEventType: params.eventType, + affiliateDedupeKey: params.dedupeKey, + }); return null; } @@ -865,6 +887,12 @@ export async function enqueueAffiliateEventForUser( .limit(1); if (!attribution) { + logImpactReferralDebug('Skipped affiliate child enqueue because attribution row was missing', { + userId: params.userId, + affiliateProvider: params.provider, + affiliateEventType: params.eventType, + affiliateDedupeKey: params.dedupeKey, + }); return null; } @@ -922,6 +950,10 @@ export async function enqueueAffiliateEventForUser( logInfo(inserted ? 'Enqueued affiliate child event' : 'Affiliate child event already exists', { ...buildAffiliateEventLogFields(event), }); + logImpactReferralDebug( + inserted ? 'Enqueued affiliate child event' : 'Affiliate child event already exists', + buildAffiliateEventLogFields(event) + ); return event; } @@ -1266,6 +1298,10 @@ export async function dispatchQueuedAffiliateEvents(params?: { }): Promise { const database = getDatabaseClient(params?.database); const limit = params?.limit ?? DEFAULT_CLAIM_LIMIT; + logImpactReferralDebug('Processing affiliate event dispatch queue', { + limit, + impactConfigured: isImpactConfigured(), + }); const summary: AffiliateEventDispatchSummary = { reclaimed: 0, claimed: 0, @@ -1335,6 +1371,10 @@ export async function dispatchQueuedAffiliateEvents(params?: { ...buildAffiliateEventLogFields(event), dispatch_source: 'cron', }); + logImpactReferralDebug('Claimed affiliate event for dispatch', { + ...buildAffiliateEventLogFields(event), + dispatch_source: 'cron', + }); if (event.event_type === 'sale_reversal') { const reversalOutcome = await dispatchSaleReversalEvent(database, event); @@ -1373,6 +1413,16 @@ export async function dispatchQueuedAffiliateEvents(params?: { dispatch_source: 'cron', } ); + logImpactReferralDebug( + result.skipped === 'unconfigured' + ? 'Skipped affiliate event delivery because Impact is unconfigured' + : 'Delivered affiliate event', + { + ...buildAffiliateEventLogFields(deliveredEvent), + dispatch_source: 'cron', + delivery: result.skipped ?? result.delivery ?? null, + } + ); if ( event.event_type === getParentEventType(event.provider) || @@ -1392,6 +1442,12 @@ export async function dispatchQueuedAffiliateEvents(params?: { } if (result.failureKind === 'http_4xx' || result.failureKind === 'submission_failed') { + logImpactReferralDebug('Affiliate event delivery failed permanently', { + ...buildAffiliateEventLogFields(event), + dispatch_source: 'cron', + failureKind: result.failureKind, + statusCode: result.statusCode ?? null, + }); await handlePermanentFailure(database, event, result.failureKind, { statusCode: result.statusCode, error: result.error ?? result.responseBody, @@ -1400,6 +1456,12 @@ export async function dispatchQueuedAffiliateEvents(params?: { continue; } + logImpactReferralDebug('Affiliate event delivery scheduled for retry', { + ...buildAffiliateEventLogFields(event), + dispatch_source: 'cron', + failureKind: result.failureKind, + statusCode: result.statusCode ?? null, + }); await handleRetryableFailure(database, event, result.failureKind, result.statusCode); summary.retried += 1; } diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index fccbce9e1a..202352cdf2 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -43,6 +43,15 @@ export const CODE_REVIEW_WORKER_AUTH_TOKEN = getEnvVariable('CODE_REVIEW_WORKER_ export const IMPACT_ACCOUNT_SID = getEnvVariable('IMPACT_ACCOUNT_SID') || ''; export const IMPACT_AUTH_TOKEN = getEnvVariable('IMPACT_AUTH_TOKEN') || ''; export const IMPACT_CAMPAIGN_ID = getEnvVariable('IMPACT_CAMPAIGN_ID') || ''; +export const IMPACT_ADVOCATE_TENANT_ALIAS = getEnvVariable('IMPACT_ADVOCATE_TENANT_ALIAS') || ''; +export const IMPACT_ADVOCATE_PROGRAM_ID = getEnvVariable('IMPACT_ADVOCATE_PROGRAM_ID') || ''; +export const IMPACT_ADVOCATE_ACCOUNT_SID = getEnvVariable('IMPACT_ADVOCATE_ACCOUNT_SID') || ''; +export const IMPACT_ADVOCATE_AUTH_TOKEN = getEnvVariable('IMPACT_ADVOCATE_AUTH_TOKEN') || ''; +export const IMPACT_ADVOCATE_WIDGET_ID = getEnvVariable('IMPACT_ADVOCATE_WIDGET_ID') || ''; +export const IMPACT_ADVOCATE_API_BASE_URL = + getEnvVariable('IMPACT_ADVOCATE_API_BASE_URL') || 'https://app.referralsaasquatch.com'; +export const IMPACT_ADVOCATE_DEBUG_LOGGING = + getEnvVariable('IMPACT_ADVOCATE_DEBUG_LOGGING') === 'true'; if (!NEXTAUTH_SECRET) throw new Error('NEXTAUTH_SECRET is required JWT signing'); if (!TURNSTILE_SECRET_KEY) throw new Error('TURNSTILE_SECRET_KEY is required'); diff --git a/apps/web/src/lib/getSignInCallbackUrl.test.ts b/apps/web/src/lib/getSignInCallbackUrl.test.ts index eb441ccbe2..c732ec75b0 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.test.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.test.ts @@ -217,6 +217,34 @@ describe('getSignInCallbackUrl', () => { expect(result).toBe('/users/after-sign-in?source=extension&im_ref=impact-click-id-123'); }); + + test('preserves referral query params through the auth callback', () => { + const result = getSignInCallbackUrl({ + _saasquatch: 'opaque-referral-cookie', + rsCode: 'ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + }); + + expect(result).toBe( + '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&rsShareMedium=email&rsEngagementMedium=link' + ); + }); + + test('preserves KiloClaw callback paths and referral UTM metadata', () => { + const result = getSignInCallbackUrl({ + callbackPath: '/claw/new', + _saasquatch: 'opaque-referral-cookie', + rsCode: 'ref-code', + utm_source: 'invite', + utm_medium: 'link', + utm_campaign: 'saasquatch', + }); + + expect(result).toBe( + '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&utm_source=invite&utm_medium=link&utm_campaign=saasquatch&callbackPath=%2Fclaw%2Fnew' + ); + }); }); describe('stripHost', () => { diff --git a/apps/web/src/lib/getSignInCallbackUrl.ts b/apps/web/src/lib/getSignInCallbackUrl.ts index 3f9a5885f7..0f2a0b41a4 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.ts @@ -16,6 +16,8 @@ export function isValidCallbackPath(path: string): boolean { path.startsWith('/get-started') || path.startsWith('/welcome/landing') || path.startsWith('/organizations/') || + path === '/claw' || + path.startsWith('/claw/') || path.startsWith('/cloud') || path.startsWith('/integrations/') || // Admin-managed URL bonus campaigns. Stricter shape enforcement @@ -36,8 +38,25 @@ export default function getSignInCallbackUrl(searchParams?: NextAppSearchParams) callbackParams.set('source', searchParams?.source); } - if (typeof searchParams?.im_ref === 'string' && searchParams?.im_ref) { - callbackParams.set('im_ref', searchParams.im_ref); + // Order matters: tests assert this exact emission order through the + // sign-in callback redirect (see getSignInCallbackUrl.test.ts). + const trackingParams = [ + 'im_ref', + '_saasquatch', + 'rsCode', + 'rsShareMedium', + 'rsEngagementMedium', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + ] as const; + for (const trackingParam of trackingParams) { + const value = searchParams?.[trackingParam]; + if (typeof value === 'string' && value) { + callbackParams.set(trackingParam, value); + } } // Always route through /users/after-sign-in to ensure stytch verification check diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts new file mode 100644 index 0000000000..4f2a4da994 --- /dev/null +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -0,0 +1,283 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import jwt from 'jsonwebtoken'; + +describe('impact advocate', () => { + const originalEnv = { + IMPACT_ADVOCATE_ACCOUNT_SID: process.env.IMPACT_ADVOCATE_ACCOUNT_SID, + IMPACT_ADVOCATE_AUTH_TOKEN: process.env.IMPACT_ADVOCATE_AUTH_TOKEN, + IMPACT_ADVOCATE_DEBUG_LOGGING: process.env.IMPACT_ADVOCATE_DEBUG_LOGGING, + IMPACT_ADVOCATE_PROGRAM_ID: process.env.IMPACT_ADVOCATE_PROGRAM_ID, + IMPACT_ADVOCATE_TENANT_ALIAS: process.env.IMPACT_ADVOCATE_TENANT_ALIAS, + IMPACT_ADVOCATE_WIDGET_ID: process.env.IMPACT_ADVOCATE_WIDGET_ID, + IMPACT_ACCOUNT_SID: process.env.IMPACT_ACCOUNT_SID, + }; + + afterEach(() => { + jest.restoreAllMocks(); + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = originalEnv.IMPACT_ADVOCATE_ACCOUNT_SID; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = originalEnv.IMPACT_ADVOCATE_AUTH_TOKEN; + process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = originalEnv.IMPACT_ADVOCATE_DEBUG_LOGGING; + process.env.IMPACT_ADVOCATE_PROGRAM_ID = originalEnv.IMPACT_ADVOCATE_PROGRAM_ID; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = originalEnv.IMPACT_ADVOCATE_TENANT_ALIAS; + process.env.IMPACT_ADVOCATE_WIDGET_ID = originalEnv.IMPACT_ADVOCATE_WIDGET_ID; + process.env.IMPACT_ACCOUNT_SID = originalEnv.IMPACT_ACCOUNT_SID; + jest.resetModules(); + }); + + it('builds register participant payloads with exact cookie attribution', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'kilo'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'account-sid'; + + const { buildImpactAdvocateRegisterParticipantPayload } = await import('@/lib/impact-advocate'); + + expect( + buildImpactAdvocateRegisterParticipantPayload({ + user: { id: 'user_123', google_user_email: 'referee@example.com' }, + referralCookieValue: 'opaque-cookie-value', + locale: 'en-US', + countryCode: 'US', + }) + ).toEqual({ + id: 'referee@example.com', + accountId: 'referee@example.com', + email: 'referee@example.com', + cookies: 'opaque-cookie-value', + // SaaSquatch wants en_US, not en-US. + locale: 'en_US', + countryCode: 'US', + }); + }); + + it('normalizes bare widget IDs to the full Impact embed widget path', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_WIDGET_ID = '51699'; + + const { getImpactAdvocateWidgetId } = await import('@/lib/impact-advocate'); + + expect(getImpactAdvocateWidgetId()).toBe('p/51699/w/referrerWidget'); + }); + + it('logs debug data without tokens, credentials, authorization headers, cookie values, or email identities', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + const { + buildImpactAdvocateRegisterParticipantPayload, + issueImpactAdvocateVerifiedAccessToken, + } = await import('@/lib/impact-advocate'); + + buildImpactAdvocateRegisterParticipantPayload({ + user: { id: 'user_123', google_user_email: 'referee@example.com' }, + referralCookieValue: 'opaque-cookie-value', + }); + issueImpactAdvocateVerifiedAccessToken( + { id: 'user_456', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z') + ); + + const loggedData = JSON.stringify(logSpy.mock.calls); + expect(loggedData).toContain('[impact-advocate] built register participant payload'); + expect(loggedData).toContain('[impact-advocate] issued verified access token'); + expect(loggedData).toContain('[omitted: email identity is PII]'); + expect(loggedData).not.toContain('referee@example.com'); + expect(loggedData).not.toContain('referrer@example.com'); + expect(loggedData).toContain('impact-account-sid'); + expect(loggedData).toContain('segmentLengths'); + expect(loggedData).toContain('[omitted: cookie value is sensitive]'); + expect(loggedData).not.toContain('opaque-cookie-value'); + expect(loggedData).not.toContain('secret'); + }); + + it('issues verified access JWTs with the account sid in the kid header', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_WIDGET_ID = 'p/51699/w/referrerWidget'; + + const { getImpactAdvocateWidgetId, issueImpactAdvocateVerifiedAccessToken } = + await import('@/lib/impact-advocate'); + + const token = issueImpactAdvocateVerifiedAccessToken( + { id: 'user_123', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z') + ); + + expect(token).toBeTruthy(); + expect(getImpactAdvocateWidgetId()).toBe('p/51699/w/referrerWidget'); + + const decoded = jwt.decode(token ?? '', { complete: true }); + if (!decoded || typeof decoded !== 'object') { + throw new Error('Expected a decoded JWT payload'); + } + + expect(decoded.header.kid).toBe('impact-account-sid'); + expect(decoded.payload).toEqual({ + user: { + id: 'referrer@example.com', + accountId: 'referrer@example.com', + email: 'referrer@example.com', + referable: false, + }, + exp: Math.floor(new Date('2026-04-23T12:00:00.000Z').getTime() / 1000) + 60 * 60, + }); + }); + + it('looks up account rewards with account and user filters', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ rewards: [{ id: 'reward-123', type: 'CREDIT' }] }), { + status: 200, + }) + ); + global.fetch = fetchMock; + + const { sendImpactAdvocateRewardLookupPayload } = await import('@/lib/impact-advocate'); + const result = await sendImpactAdvocateRewardLookupPayload({ + accountId: 'user@example.com', + userId: 'user@example.com', + rewardTypeFilter: 'CREDIT', + }); + + expect(result).toEqual({ + ok: true, + statusCode: 200, + responseBody: '{"rewards":[{"id":"reward-123","type":"CREDIT"}]}', + rewards: [{ id: 'reward-123', type: 'CREDIT' }], + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://app.referralsaasquatch.com/api/v1/tenant-alias/reward?accountId=user%40example.com&userId=user%40example.com&rewardTypeFilter=CREDIT' + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Basic ' + Buffer.from('impact-account-sid:secret').toString('base64'), + Accept: 'application/json', + }), + }); + const loggedData = JSON.stringify(logSpy.mock.calls); + expect(loggedData).toContain('accountId=redacted'); + expect(loggedData).toContain('userId=redacted'); + expect(loggedData).not.toContain('user@example.com'); + }); + + it('redeems a credit reward with amount and unit', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + const fetchMock = jest + .fn() + .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + global.fetch = fetchMock; + + const { sendImpactAdvocateRewardRedemptionPayload } = await import('@/lib/impact-advocate'); + const result = await sendImpactAdvocateRewardRedemptionPayload({ + rewardId: 'reward-123', + amount: 1, + unit: 'free-months', + }); + + expect(result).toEqual({ ok: true, statusCode: 200, responseBody: '{"ok":true}' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://app.referralsaasquatch.com/api/v1/tenant-alias/credit/reward-123/redeem' + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic ' + Buffer.from('impact-account-sid:secret').toString('base64'), + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + body: '{"amount":1,"unit":"free-months"}', + }); + }); + + it('strips legacy programId and normalises locale at send time', async () => { + const { sanitizeRegisterParticipantPayloadForWire } = await import('@/lib/impact-advocate'); + + // Legacy persisted shape: extra programId, BCP 47 locale, plus an unknown + // garbage field. Sanitiser must produce SaaSquatch-acceptable JSON. + const sanitized = sanitizeRegisterParticipantPayloadForWire({ + id: 'referee@example.com', + accountId: 'referee@example.com', + email: 'referee@example.com', + cookies: 'sq-cookie', + locale: 'en-US', + countryCode: 'US', + programId: '51699', + garbage: 'should be dropped', + }); + + expect(sanitized).toEqual({ + id: 'referee@example.com', + accountId: 'referee@example.com', + email: 'referee@example.com', + cookies: 'sq-cookie', + locale: 'en_US', + countryCode: 'US', + }); + }); + + describe('extractAdvocateReferralCodeFromUpsertResponse', () => { + it('returns the program-scoped code from a SaaSquatch upsert response', async () => { + const { extractAdvocateReferralCodeFromUpsertResponse } = + await import('@/lib/impact-advocate'); + + const body = JSON.stringify({ + id: 'hash', + email: 'referee@example.com', + referralCodes: { '51699': 'REFEREE15914', '99999': 'OTHER42' }, + referable: true, + }); + + expect(extractAdvocateReferralCodeFromUpsertResponse(body, '51699')).toBe('REFEREE15914'); + expect(extractAdvocateReferralCodeFromUpsertResponse(body, '99999')).toBe('OTHER42'); + }); + + it('returns null for missing program, malformed JSON, empty bodies, or non-string codes', async () => { + const { extractAdvocateReferralCodeFromUpsertResponse } = + await import('@/lib/impact-advocate'); + + expect(extractAdvocateReferralCodeFromUpsertResponse(null, '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('', '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('not json', '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('null', '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('{}', '51699')).toBeNull(); + expect( + extractAdvocateReferralCodeFromUpsertResponse( + JSON.stringify({ referralCodes: { '51699': ' ' } }), + '51699' + ) + ).toBeNull(); + expect( + extractAdvocateReferralCodeFromUpsertResponse( + JSON.stringify({ referralCodes: { '51699': 12345 } }), + '51699' + ) + ).toBeNull(); + expect( + extractAdvocateReferralCodeFromUpsertResponse( + JSON.stringify({ referralCodes: { '99999': 'OTHER42' } }), + '51699' + ) + ).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts new file mode 100644 index 0000000000..b4f0b4b619 --- /dev/null +++ b/apps/web/src/lib/impact-advocate.ts @@ -0,0 +1,649 @@ +import 'server-only'; + +import jwt from 'jsonwebtoken'; +import type { SignOptions } from 'jsonwebtoken'; +import type { User } from '@kilocode/db/schema'; +import { + IMPACT_ACCOUNT_SID, + IMPACT_ADVOCATE_ACCOUNT_SID, + IMPACT_ADVOCATE_API_BASE_URL, + IMPACT_ADVOCATE_AUTH_TOKEN, + IMPACT_ADVOCATE_PROGRAM_ID, + IMPACT_ADVOCATE_TENANT_ALIAS, + IMPACT_ADVOCATE_WIDGET_ID, +} from '@/lib/config.server'; +import { logImpactReferralDebug, truncateForLog } from '@/lib/impact-debug'; + +/** + * SaaSquatch / Impact Advocate expects locale tags formatted as `en_US`, + * not the BCP 47 `en-US` we get from Accept-Language. Normalize once here + * so the value is consistent both on the wire and in the persisted payload. + */ +function normalizeAdvocateLocale(locale: string | null | undefined): string | null { + const trimmed = locale?.trim(); + if (!trimmed) return null; + return trimmed.replace(/-/g, '_'); +} + +export const IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID = '51699'; +export const IMPACT_ADVOCATE_DEFAULT_WIDGET_ID = 'p/51699/w/referrerWidget'; +const IMPACT_ADVOCATE_WIDGET_NAME = 'referrerWidget'; +const IMPACT_ADVOCATE_VERIFIED_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; + +export type ImpactAdvocateIdentityPayload = { + id: string; + accountId: string; + email: string; + referable: boolean; +}; + +/** + * SaaSquatch / Impact Advocate Upsert User accepts a strict allow-list of + * fields. Per the program integration spec, these are the only keys SaaSquatch + * will accept; any extra field is rejected with `INVALID_JSON_REQUEST`. + * + * Required: id, accountId, email, cookies. + * Optional: firstName, lastName, locale, countryCode, segments, customFields. + * + * Note: `programId` is intentionally NOT part of this type. Earlier code + * persisted it into request_payload rows; sanitizeRegisterParticipantPayloadForWire + * strips it (and any other unknown field) before the request goes out, so old + * rows can still be retried without a data migration. + */ +export type ImpactAdvocateRegisterParticipantPayload = { + id: string; + accountId: string; + email: string; + cookies: string; + firstName?: string; + lastName?: string; + locale?: string; + countryCode?: string; + segments?: string[]; + customFields?: Record; +}; + +const REGISTER_PARTICIPANT_ALLOWED_FIELDS = new Set([ + 'id', + 'accountId', + 'email', + 'cookies', + 'firstName', + 'lastName', + 'locale', + 'countryCode', + 'segments', + 'customFields', +]); + +/** + * Allow-list filter applied at the moment we hit the wire. Drops anything + * SaaSquatch would reject and re-normalises locale (`en-US` -> `en_US`) so + * persisted rows from before the locale fix retry cleanly. + */ +export function sanitizeRegisterParticipantPayloadForWire( + payload: Record +): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(payload)) { + if (!REGISTER_PARTICIPANT_ALLOWED_FIELDS.has(key)) continue; + if (key === 'locale' && typeof value === 'string') { + const normalized = normalizeAdvocateLocale(value); + if (normalized) sanitized[key] = normalized; + continue; + } + sanitized[key] = value; + } + return sanitized; +} + +type ImpactAdvocateVerifiedAccessTokenPayload = { + user: ImpactAdvocateIdentityPayload; + exp: number; +}; + +type ImpactAdvocateJwtHeaderInput = { + alg: 'HS256'; + kid: string; +}; + +export type ImpactAdvocateDispatchResult = + | { + ok: true; + responseBody?: string; + statusCode?: number; + } + | { + ok: false; + failureKind: 'http_4xx' | 'http_5xx' | 'network'; + statusCode?: number; + responseBody?: string; + error?: string; + }; + +export type ImpactAdvocateRewardLookupPayload = { + accountId: string; + userId?: string; + rewardTypeFilter?: 'CREDIT'; +}; + +export type ImpactAdvocateRewardRedemptionPayload = { + rewardId: string; + amount: number; + unit: string; +}; + +export type ImpactAdvocateRewardListResult = ImpactAdvocateDispatchResult & { + rewards?: unknown[]; +}; + +function redactAdvocateEmailIdentityForLog(value: string | null | undefined): string | null { + return value?.trim() ? '[omitted: email identity is PII]' : null; +} + +function truncateAndRedactAdvocateResponseForLog(value: string | null | undefined): string | null { + return ( + truncateForLog(value)?.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted-email]') ?? + null + ); +} + +function getDebuggableRegisterParticipantPayload( + payload: ImpactAdvocateRegisterParticipantPayload +) { + return { + id: redactAdvocateEmailIdentityForLog(payload.id), + accountId: redactAdvocateEmailIdentityForLog(payload.accountId), + email: redactAdvocateEmailIdentityForLog(payload.email), + cookies: '[omitted: cookie value is sensitive]', + firstName: payload.firstName ? '[omitted: name is PII]' : undefined, + lastName: payload.lastName ? '[omitted: name is PII]' : undefined, + locale: payload.locale, + countryCode: payload.countryCode, + segments: payload.segments, + customFieldsPresent: payload.customFields ? true : undefined, + }; +} + +function getDebuggableVerifiedAccessTokenPayload( + payload: ImpactAdvocateVerifiedAccessTokenPayload +): ImpactAdvocateVerifiedAccessTokenPayload { + return { + ...payload, + user: { + ...payload.user, + id: redactAdvocateEmailIdentityForLog(payload.user.id) ?? '', + accountId: redactAdvocateEmailIdentityForLog(payload.user.accountId) ?? '', + email: redactAdvocateEmailIdentityForLog(payload.user.email) ?? '', + }, + }; +} + +function getImpactAdvocateWidgetPath(widgetId: string, programId: string): string { + const trimmedWidgetId = widgetId.trim(); + if (!trimmedWidgetId) return `p/${programId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; + if (trimmedWidgetId.includes('/')) return trimmedWidgetId; + return `p/${trimmedWidgetId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; +} + +function getImpactAdvocateConfig() { + const accountSid = IMPACT_ADVOCATE_ACCOUNT_SID || IMPACT_ACCOUNT_SID; + const authToken = IMPACT_ADVOCATE_AUTH_TOKEN; + const tenantAlias = IMPACT_ADVOCATE_TENANT_ALIAS; + const programId = IMPACT_ADVOCATE_PROGRAM_ID || IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; + const widgetId = getImpactAdvocateWidgetPath(IMPACT_ADVOCATE_WIDGET_ID, programId); + + if (!accountSid || !authToken || !tenantAlias) { + return null; + } + + return { + accountSid, + authToken, + tenantAlias, + programId, + widgetId, + }; +} + +export function isImpactAdvocateConfigured(): boolean { + return getImpactAdvocateConfig() !== null; +} + +export function getImpactAdvocateWidgetId(): string { + return getImpactAdvocateConfig()?.widgetId ?? IMPACT_ADVOCATE_DEFAULT_WIDGET_ID; +} + +export function getImpactAdvocateProgramId(): string { + return getImpactAdvocateConfig()?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; +} + +/** + * Pull the program-scoped referral code out of a SaaSquatch Upsert User + * response body. The response shape is: + * + * { ..., "referralCodes": { "": "" }, ... } + * + * Returns null when the body is missing, malformed, or does not contain a + * code for the requested programId. Never throws — callers treat null as + * "no code, leave participants.opaque_referral_identifier alone". + */ +export function extractAdvocateReferralCodeFromUpsertResponse( + responseBody: string | null | undefined, + programId: string +): string | null { + if (!responseBody) return null; + let parsed: unknown; + try { + parsed = JSON.parse(responseBody); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const referralCodes = (parsed as Record).referralCodes; + if (typeof referralCodes !== 'object' || referralCodes === null) return null; + const code = (referralCodes as Record)[programId]; + if (typeof code !== 'string') return null; + const trimmed = code.trim(); + return trimmed ? trimmed : null; +} + +export function buildImpactAdvocateIdentityPayload( + user: Pick +): ImpactAdvocateIdentityPayload { + return { + id: user.google_user_email, + accountId: user.google_user_email, + email: user.google_user_email, + referable: false, + }; +} + +export function buildImpactAdvocateRegisterParticipantPayload(params: { + user: Pick; + referralCookieValue: string; + locale?: string | null; + countryCode?: string | null; +}): ImpactAdvocateRegisterParticipantPayload { + const normalizedLocale = normalizeAdvocateLocale(params.locale); + const payload: ImpactAdvocateRegisterParticipantPayload = { + id: params.user.google_user_email, + accountId: params.user.google_user_email, + email: params.user.google_user_email, + cookies: params.referralCookieValue, + ...(normalizedLocale ? { locale: normalizedLocale } : {}), + ...(params.countryCode ? { countryCode: params.countryCode } : {}), + }; + + logImpactReferralDebug('[impact-advocate] built register participant payload', { + payload: getDebuggableRegisterParticipantPayload(payload), + }); + + return payload; +} + +function getImpactAdvocateAuthorizationHeader( + config: NonNullable> +): string { + return `Basic ${Buffer.from(`${config.accountSid}:${config.authToken}`).toString('base64')}`; +} + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getCaseInsensitiveProperty(record: Record, key: string): unknown { + if (Object.prototype.hasOwnProperty.call(record, key)) { + return record[key]; + } + + const lowerKey = key.toLowerCase(); + const matchedKey = Object.keys(record).find(candidate => candidate.toLowerCase() === lowerKey); + return matchedKey ? record[matchedKey] : undefined; +} + +export function extractImpactAdvocateRewards(responseBody: string | null | undefined): unknown[] { + if (!responseBody) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(responseBody); + } catch { + return []; + } + + if (Array.isArray(parsed)) return parsed; + if (!isRecord(parsed)) return []; + + const candidateKeys = [ + 'rewards', + 'Rewards', + 'data', + 'Data', + 'items', + 'Items', + 'results', + 'Results', + ]; + for (const key of candidateKeys) { + const candidate = getCaseInsensitiveProperty(parsed, key); + if (Array.isArray(candidate)) return candidate; + } + + return []; +} + +/** + * SaaSquatch (Impact Advocate) Upsert User REST endpoint. + * + * PUT {base}/api/v1/{tenantAlias}/open/account/{accountId}/user/{userId} + * + * accountId and userId are both the user's plain email per the program's + * integration spec; we URL-encode them because the path segment contains '@'. + */ +function getImpactAdvocateRegisterParticipantUrl( + config: NonNullable>, + payload: ImpactAdvocateRegisterParticipantPayload +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + const accountId = encodeURIComponent(payload.accountId); + const userId = encodeURIComponent(payload.id); + return `${base}/api/v1/${tenant}/open/account/${accountId}/user/${userId}`; +} + +function getDebuggableImpactAdvocateRegisterParticipantUrl( + config: NonNullable> +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + return `${base}/api/v1/${tenant}/open/account/[redacted-account-id]/user/[redacted-user-id]`; +} + +function getImpactAdvocateRewardsUrl( + config: NonNullable>, + payload: ImpactAdvocateRewardLookupPayload +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + const url = new URL(`${base}/api/v1/${tenant}/reward`); + url.searchParams.set('accountId', payload.accountId); + if (payload.userId) url.searchParams.set('userId', payload.userId); + if (payload.rewardTypeFilter) url.searchParams.set('rewardTypeFilter', payload.rewardTypeFilter); + return url.toString(); +} + +function getDebuggableImpactAdvocateRewardsUrl( + config: NonNullable>, + payload: ImpactAdvocateRewardLookupPayload +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + const url = new URL(`${base}/api/v1/${tenant}/reward`); + url.searchParams.set('accountId', 'redacted'); + if (payload.userId) url.searchParams.set('userId', 'redacted'); + if (payload.rewardTypeFilter) url.searchParams.set('rewardTypeFilter', payload.rewardTypeFilter); + return url.toString(); +} + +function getImpactAdvocateRedeemRewardUrl( + config: NonNullable>, + rewardId: string +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + return `${base}/api/v1/${tenant}/credit/${encodeURIComponent(rewardId)}/redeem`; +} + +export async function sendImpactAdvocateRegisterParticipantPayload( + payload: ImpactAdvocateRegisterParticipantPayload +): Promise { + const config = getImpactAdvocateConfig(); + if (!config) { + return { + ok: false, + failureKind: 'http_4xx', + error: 'Impact Advocate is unconfigured', + }; + } + + try { + const url = getImpactAdvocateRegisterParticipantUrl(config, payload); + const sanitizedPayload = sanitizeRegisterParticipantPayloadForWire( + payload as unknown as Record + ); + logImpactReferralDebug('[impact-advocate] sending register participant request', { + url: getDebuggableImpactAdvocateRegisterParticipantUrl(config), + method: 'PUT', + headers: { + Authorization: 'not_logged', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + payload: getDebuggableRegisterParticipantPayload( + sanitizedPayload as ImpactAdvocateRegisterParticipantPayload + ), + }); + + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: getImpactAdvocateAuthorizationHeader(config), + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(sanitizedPayload), + }); + + const responseBody = await response.text(); + logImpactReferralDebug('[impact-advocate] register participant response', { + url: getDebuggableImpactAdvocateRegisterParticipantUrl(config), + ok: response.ok, + statusCode: response.status, + responseBody: truncateAndRedactAdvocateResponseForLog(responseBody), + }); + + if (response.ok) { + return { + ok: true, + statusCode: response.status, + responseBody, + }; + } + + return { + ok: false, + failureKind: response.status >= 500 ? 'http_5xx' : 'http_4xx', + statusCode: response.status, + responseBody, + }; + } catch (error) { + logImpactReferralDebug('[impact-advocate] register participant network error', { + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: false, + failureKind: 'network', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function sendImpactAdvocateRewardLookupPayload( + payload: ImpactAdvocateRewardLookupPayload +): Promise { + const config = getImpactAdvocateConfig(); + if (!config) { + return { + ok: false, + failureKind: 'http_4xx', + error: 'Impact Advocate is unconfigured', + }; + } + + try { + const url = getImpactAdvocateRewardsUrl(config, payload); + logImpactReferralDebug('[impact-advocate] sending reward lookup request', { + url: getDebuggableImpactAdvocateRewardsUrl(config, payload), + method: 'GET', + accountIdPresent: Boolean(payload.accountId.trim()), + userIdPresent: Boolean(payload.userId?.trim()), + rewardTypeFilter: payload.rewardTypeFilter ?? null, + }); + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: getImpactAdvocateAuthorizationHeader(config), + Accept: 'application/json', + }, + }); + const responseBody = await response.text(); + + logImpactReferralDebug('[impact-advocate] reward lookup response', { + url: getDebuggableImpactAdvocateRewardsUrl(config, payload), + ok: response.ok, + statusCode: response.status, + responseBody: truncateAndRedactAdvocateResponseForLog(responseBody), + }); + + if (response.ok) { + return { + ok: true, + statusCode: response.status, + responseBody, + rewards: extractImpactAdvocateRewards(responseBody), + }; + } + + return { + ok: false, + failureKind: response.status >= 500 ? 'http_5xx' : 'http_4xx', + statusCode: response.status, + responseBody, + }; + } catch (error) { + logImpactReferralDebug('[impact-advocate] reward lookup network error', { + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: false, + failureKind: 'network', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function sendImpactAdvocateRewardRedemptionPayload( + payload: ImpactAdvocateRewardRedemptionPayload +): Promise { + const config = getImpactAdvocateConfig(); + if (!config) { + return { + ok: false, + failureKind: 'http_4xx', + error: 'Impact Advocate is unconfigured', + }; + } + + try { + const url = getImpactAdvocateRedeemRewardUrl(config, payload.rewardId); + const body = { + amount: payload.amount, + unit: payload.unit, + }; + logImpactReferralDebug('[impact-advocate] sending reward redemption request', { + url, + method: 'POST', + rewardIdPresent: Boolean(payload.rewardId.trim()), + amount: payload.amount, + unit: payload.unit, + }); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: getImpactAdvocateAuthorizationHeader(config), + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + const responseBody = await response.text(); + + logImpactReferralDebug('[impact-advocate] reward redemption response', { + url, + ok: response.ok, + statusCode: response.status, + responseBody: truncateAndRedactAdvocateResponseForLog(responseBody), + }); + + if (response.ok) { + return { + ok: true, + statusCode: response.status, + responseBody, + }; + } + + return { + ok: false, + failureKind: response.status >= 500 ? 'http_5xx' : 'http_4xx', + statusCode: response.status, + responseBody, + }; + } catch (error) { + logImpactReferralDebug('[impact-advocate] reward redemption network error', { + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: false, + failureKind: 'network', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function issueImpactAdvocateVerifiedAccessToken( + user: Pick, + now: Date = new Date() +): string | null { + const config = getImpactAdvocateConfig(); + if (!config) return null; + + const header: ImpactAdvocateJwtHeaderInput = { + alg: 'HS256', + kid: config.accountSid, + }; + const options: SignOptions = { + algorithm: 'HS256', + header, + noTimestamp: true, + }; + const payload: ImpactAdvocateVerifiedAccessTokenPayload = { + user: buildImpactAdvocateIdentityPayload(user), + exp: Math.floor(now.getTime() / 1000) + IMPACT_ADVOCATE_VERIFIED_ACCESS_TOKEN_TTL_SECONDS, + }; + const token = jwt.sign(payload, config.authToken, options); + + logImpactReferralDebug('[impact-advocate] issued verified access token', { + jwtHeader: header, + jwtPayload: getDebuggableVerifiedAccessTokenPayload(payload), + signOptions: { + algorithm: options.algorithm, + noTimestamp: options.noTimestamp, + expiresIn: options.expiresIn ?? null, + subject: options.subject ?? null, + }, + token: { + omitted: 'not_logged', + segmentLengths: token.split('.').map(segment => segment.length), + }, + }); + + return token; +} diff --git a/apps/web/src/lib/impact-affiliate-utils.test.ts b/apps/web/src/lib/impact-affiliate-utils.test.ts index 4d9bae14ad..00bf383cfb 100644 --- a/apps/web/src/lib/impact-affiliate-utils.test.ts +++ b/apps/web/src/lib/impact-affiliate-utils.test.ts @@ -30,6 +30,48 @@ describe('impact affiliate utils', () => { }); }); + it('can ignore URL im_ref when it belongs to the current referral touch', () => { + expect( + resolveImpactAffiliateTrackingId({ + imRefParam: 'impact-click-from-referral-url', + sharedImpactCookieValue: null, + appTrackedImpactCookieValue: null, + ignoreImRefParam: true, + }) + ).toEqual({ + affiliateTrackingId: null, + impactCookieValue: null, + }); + }); + + it('falls back to a prior shared cookie when ignoring the current URL im_ref', () => { + expect( + resolveImpactAffiliateTrackingId({ + imRefParam: 'impact-click-from-referral-url', + sharedImpactCookieValue: 'impact-click-from-cookie', + appTrackedImpactCookieValue: null, + ignoreImRefParam: true, + }) + ).toEqual({ + affiliateTrackingId: 'impact-click-from-cookie', + impactCookieValue: 'impact-click-from-cookie', + }); + }); + + it('does not recover the ignored URL im_ref from the shared cookie', () => { + expect( + resolveImpactAffiliateTrackingId({ + imRefParam: 'impact-click-from-referral-url', + sharedImpactCookieValue: 'impact-click-from-referral-url', + appTrackedImpactCookieValue: null, + ignoreImRefParam: true, + }) + ).toEqual({ + affiliateTrackingId: null, + impactCookieValue: null, + }); + }); + it('suppresses the shared cookie when the app already tracked that exact value', () => { expect( resolveImpactAffiliateTrackingId({ diff --git a/apps/web/src/lib/impact-affiliate-utils.ts b/apps/web/src/lib/impact-affiliate-utils.ts index a7aef8fa7e..12cb8df0d0 100644 --- a/apps/web/src/lib/impact-affiliate-utils.ts +++ b/apps/web/src/lib/impact-affiliate-utils.ts @@ -13,16 +13,23 @@ export function resolveImpactAffiliateTrackingId(params: { imRefParam: string | null; sharedImpactCookieValue: string | null; appTrackedImpactCookieValue: string | null; + ignoreImRefParam?: boolean; }) { - const impactCookieValue = params.imRefParam + const ignoredImRefParam = params.ignoreImRefParam ? params.imRefParam : null; + const imRefParam = params.ignoreImRefParam ? null : params.imRefParam; + const sharedCookieMatchesIgnoredImRef = Boolean( + ignoredImRefParam && params.sharedImpactCookieValue === ignoredImRefParam + ); + const impactCookieValue = imRefParam ? null : params.sharedImpactCookieValue && - params.sharedImpactCookieValue !== params.appTrackedImpactCookieValue + params.sharedImpactCookieValue !== params.appTrackedImpactCookieValue && + !sharedCookieMatchesIgnoredImRef ? params.sharedImpactCookieValue : null; return { - affiliateTrackingId: params.imRefParam || impactCookieValue, + affiliateTrackingId: imRefParam || impactCookieValue, impactCookieValue, }; } diff --git a/apps/web/src/lib/impact-debug.ts b/apps/web/src/lib/impact-debug.ts new file mode 100644 index 0000000000..a634e6bc9c --- /dev/null +++ b/apps/web/src/lib/impact-debug.ts @@ -0,0 +1,28 @@ +/** + * Returns true when the unified Impact debug logger should emit. Honors: + * - NODE_ENV === 'development' (always-on locally) + * - IMPACT_REFERRAL_DEBUG=true (server-side opt-in for staging/prod tests) + * - IMPACT_ADVOCATE_DEBUG_LOGGING=true|1|yes (legacy flag still honored) + */ +export function isImpactDebugLoggingEnabled(): boolean { + if (process.env.NODE_ENV === 'development') return true; + if (process.env.IMPACT_REFERRAL_DEBUG === 'true') return true; + const advocate = process.env.IMPACT_ADVOCATE_DEBUG_LOGGING?.trim().toLowerCase(); + return advocate === 'true' || advocate === '1' || advocate === 'yes'; +} + +export function logImpactReferralDebug(message: string, fields?: Record): void { + if (!isImpactDebugLoggingEnabled()) return; + + console.log('[impact-referral-debug]', message, { + at: new Date().toISOString(), + ...(fields ?? {}), + }); +} + +/** Truncate a response body for safe logging. Impact responses can be large. */ +export function truncateForLog(body: string | null | undefined, max = 500): string | null { + if (body == null) return null; + if (body.length <= max) return body; + return `${body.slice(0, max)}… [truncated ${body.length - max} chars]`; +} diff --git a/apps/web/src/lib/impact-referral-utils.test.ts b/apps/web/src/lib/impact-referral-utils.test.ts new file mode 100644 index 0000000000..c344ad0405 --- /dev/null +++ b/apps/web/src/lib/impact-referral-utils.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from '@jest/globals'; +import { + IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH, + IMPACT_REFERRAL_TOUCH_VALIDITY_MS, + parseImpactAffiliateTouchFromUrl, + parseImpactReferralTouchFromUrl, + redactLandingPathForLogs, + redactOpaqueTrackingValueForLogs, + sanitizeOpaqueTrackingValue, +} from '@/lib/impact-referral-utils'; + +describe('impact referral utils', () => { + it('accepts opaque tracking values within the configured limit', () => { + expect(sanitizeOpaqueTrackingValue('abc123')).toEqual({ + acceptedValue: 'abc123', + originalLength: 6, + isAccepted: true, + }); + }); + + it('rejects opaque tracking values above the configured limit', () => { + const tooLongValue = 'x'.repeat(IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH + 1); + expect(sanitizeOpaqueTrackingValue(tooLongValue)).toEqual({ + acceptedValue: null, + originalLength: tooLongValue.length, + isAccepted: false, + }); + }); + + it('redacts opaque tracking values for logs without exposing the full value', () => { + expect(redactOpaqueTrackingValueForLogs('abcd1234wxyz5678')).toBe('abcd…5678'); + expect(redactOpaqueTrackingValueForLogs('tiny')).toBe('[redacted]'); + expect(redactOpaqueTrackingValueForLogs(null)).toBeNull(); + }); + + it('redacts landing path query values for logs', () => { + expect( + redactLandingPathForLogs('/get-started?_saasquatch=sq-cookie&rsCode=abc&utm_campaign=launch') + ).toBe('/get-started?_saasquatch=redacted&rsCode=redacted&utm_campaign=redacted'); + expect(redactLandingPathForLogs('/get-started')).toBe('/get-started'); + expect(redactLandingPathForLogs(null)).toBeNull(); + }); + + it('parses referral touches and applies a 30 day expiry window', () => { + const now = new Date('2026-04-23T10:00:00.000Z'); + const touch = parseImpactReferralTouchFromUrl( + 'https://kilo.ai/get-started?_saasquatch=sq-cookie&rsCode=abc&rsShareMedium=email&rsEngagementMedium=link&utm_source=impact', + now + ); + + expect(touch).toEqual({ + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'abc', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: + '/get-started?_saasquatch=sq-cookie&rsCode=abc&rsShareMedium=email&rsEngagementMedium=link&utm_source=impact', + utmSource: 'impact', + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: now, + expiresAt: new Date(now.getTime() + IMPACT_REFERRAL_TOUCH_VALIDITY_MS), + }); + }); + + it('keeps referral metadata for diagnostics when _saasquatch is missing', () => { + const touch = parseImpactReferralTouchFromUrl( + 'https://kilo.ai/get-started?rsCode=abc&rsShareMedium=email' + ); + + expect(touch?.opaqueTrackingValue).toBeNull(); + expect(touch?.trackingValueLength).toBe(0); + expect(touch?.isTrackingValueAccepted).toBe(false); + expect(touch?.rsCode).toBe('abc'); + }); + + it('parses affiliate touches from im_ref and override cookies', () => { + const fromQuery = parseImpactAffiliateTouchFromUrl('https://kilo.ai/?im_ref=impact-click'); + expect(fromQuery?.trackingId).toBe('impact-click'); + + const fromCookie = parseImpactAffiliateTouchFromUrl('https://kilo.ai/', 'impact-cookie-click'); + expect(fromCookie?.trackingId).toBe('impact-cookie-click'); + }); +}); diff --git a/apps/web/src/lib/impact-referral-utils.ts b/apps/web/src/lib/impact-referral-utils.ts new file mode 100644 index 0000000000..e557d90b88 --- /dev/null +++ b/apps/web/src/lib/impact-referral-utils.ts @@ -0,0 +1,182 @@ +export const IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH = 512; +export const IMPACT_REFERRAL_TOUCH_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000; +export const IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY = 'impact_custom_profile_id'; + +export type SanitizedOpaqueTrackingValue = { + acceptedValue: string | null; + originalLength: number; + isAccepted: boolean; +}; + +export type ParsedImpactReferralTouch = { + opaqueTrackingValue: string | null; + trackingValueLength: number; + isTrackingValueAccepted: boolean; + rsCode: string | null; + rsShareMedium: string | null; + rsEngagementMedium: string | null; + landingPath: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmTerm: string | null; + utmContent: string | null; + touchedAt: Date; + expiresAt: Date; +}; + +export type ParsedImpactAffiliateTouch = { + trackingId: string | null; + trackingValueLength: number; + isTrackingValueAccepted: boolean; + landingPath: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmTerm: string | null; + utmContent: string | null; + touchedAt: Date; + expiresAt: Date; +}; + +function normalizeInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function sanitizeMetadataValue(value: string | null | undefined): string | null { + const normalized = normalizeInput(value); + if (!normalized || normalized.length > IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH) { + return null; + } + return normalized; +} + +function landingPathFromUrl(url: URL): string | null { + const path = `${url.pathname}${url.search}`.trim(); + return path ? path : null; +} + +export function sanitizeOpaqueTrackingValue( + value: string | null | undefined +): SanitizedOpaqueTrackingValue { + const normalized = normalizeInput(value); + const originalLength = normalized?.length ?? 0; + + if (!normalized) { + return { + acceptedValue: null, + originalLength, + isAccepted: false, + }; + } + + if (normalized.length > IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH) { + return { + acceptedValue: null, + originalLength, + isAccepted: false, + }; + } + + return { + acceptedValue: normalized, + originalLength, + isAccepted: true, + }; +} + +export function redactOpaqueTrackingValueForLogs(value: string | null | undefined): string | null { + const normalized = normalizeInput(value); + if (!normalized) return null; + + if (normalized.length <= 8) { + return '[redacted]'; + } + + return `${normalized.slice(0, 4)}…${normalized.slice(-4)}`; +} + +export function redactLandingPathForLogs(value: string | null | undefined): string | null { + const normalized = normalizeInput(value); + if (!normalized) return null; + + try { + const url = new URL(normalized, 'http://localhost'); + const redactedSearchParams = new URLSearchParams(); + for (const [key] of url.searchParams) { + redactedSearchParams.append(key, 'redacted'); + } + const search = redactedSearchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ''}`; + } catch { + return '[redacted: invalid landing path]'; + } +} + +export function parseImpactReferralTouchFromUrl( + candidateUrl: string | URL, + now: Date = new Date() +): ParsedImpactReferralTouch | null { + const url = + typeof candidateUrl === 'string' ? new URL(candidateUrl, 'http://localhost') : candidateUrl; + const searchParams = url.searchParams; + + const hasReferralSignals = ['_saasquatch', 'rsCode', 'rsShareMedium', 'rsEngagementMedium'].some( + key => normalizeInput(searchParams.get(key)) + ); + + if (!hasReferralSignals) { + return null; + } + + const trackingValue = sanitizeOpaqueTrackingValue(searchParams.get('_saasquatch')); + + return { + opaqueTrackingValue: trackingValue.acceptedValue, + trackingValueLength: trackingValue.originalLength, + isTrackingValueAccepted: trackingValue.isAccepted, + rsCode: sanitizeMetadataValue(searchParams.get('rsCode')), + rsShareMedium: sanitizeMetadataValue(searchParams.get('rsShareMedium')), + rsEngagementMedium: sanitizeMetadataValue(searchParams.get('rsEngagementMedium')), + landingPath: landingPathFromUrl(url), + utmSource: sanitizeMetadataValue(searchParams.get('utm_source')), + utmMedium: sanitizeMetadataValue(searchParams.get('utm_medium')), + utmCampaign: sanitizeMetadataValue(searchParams.get('utm_campaign')), + utmTerm: sanitizeMetadataValue(searchParams.get('utm_term')), + utmContent: sanitizeMetadataValue(searchParams.get('utm_content')), + touchedAt: now, + expiresAt: new Date(now.getTime() + IMPACT_REFERRAL_TOUCH_VALIDITY_MS), + }; +} + +export function parseImpactAffiliateTouchFromUrl( + candidateUrl: string | URL, + trackingIdOverride?: string | null, + now: Date = new Date() +): ParsedImpactAffiliateTouch | null { + const url = + typeof candidateUrl === 'string' ? new URL(candidateUrl, 'http://localhost') : candidateUrl; + const searchParams = url.searchParams; + const trackingValue = sanitizeOpaqueTrackingValue( + trackingIdOverride ?? searchParams.get('im_ref') + ); + + if (!trackingValue.acceptedValue && trackingValue.originalLength === 0) { + return null; + } + + return { + trackingId: trackingValue.acceptedValue, + trackingValueLength: trackingValue.originalLength, + isTrackingValueAccepted: trackingValue.isAccepted, + landingPath: landingPathFromUrl(url), + utmSource: sanitizeMetadataValue(searchParams.get('utm_source')), + utmMedium: sanitizeMetadataValue(searchParams.get('utm_medium')), + utmCampaign: sanitizeMetadataValue(searchParams.get('utm_campaign')), + utmTerm: sanitizeMetadataValue(searchParams.get('utm_term')), + utmContent: sanitizeMetadataValue(searchParams.get('utm_content')), + touchedAt: now, + expiresAt: new Date(now.getTime() + IMPACT_REFERRAL_TOUCH_VALIDITY_MS), + }; +} diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts new file mode 100644 index 0000000000..4d06b21150 --- /dev/null +++ b/apps/web/src/lib/impact-referral.test.ts @@ -0,0 +1,542 @@ +process.env.NEXTAUTH_SECRET ||= 'test-nextauth-secret'; +process.env.TURNSTILE_SECRET_KEY ||= 'test-turnstile-secret'; + +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { eq, sql } from 'drizzle-orm'; + +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + impact_advocate_participants, + impact_advocate_registration_attempts, + kilocode_users, +} from '@kilocode/db/schema'; + +describe('impact referral participant registration dispatch', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + process.env.IMPACT_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-advocate-account-sid'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'impact-advocate-auth-token'; + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await db.delete(impact_advocate_registration_attempts).where(sql`true`); + await db.delete(impact_advocate_participants).where(sql`true`); + await db.delete(kilocode_users).where(sql`true`); + }); + + it('delivers queued participant registrations and marks the participant registered', async () => { + // Realistic SaaSquatch upsert response shape — the dispatcher must parse + // referralCodes[programId] and persist it as the participant's + // opaque_referral_identifier so future referee touches can resolve back + // to this user as their advocate. + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-hash-id', + accountId: 'sq-hash-id', + email: 'participant@example.com', + referralCodes: { '51699': 'PARTICIPANT9001' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'participant@example.com', + normalized_email: 'participant@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch: { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/get-started?_saasquatch=sq-cookie', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + locale: 'en-US', + countryCode: 'US', + }); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + const [participant] = await db.select().from(impact_advocate_participants); + expect(participant.registration_state).toBe('registered'); + expect(participant.registered_at).toBeTruthy(); + expect(participant.last_error_code).toBeNull(); + // The advocate's program-scoped SaaSquatch code is now persisted so the + // attribution lookup in kiloclaw-referrals.ts can resolve referrerUserId. + expect(participant.opaque_referral_identifier).toBe('PARTICIPANT9001'); + + const [attempt] = await db.select().from(impact_advocate_registration_attempts); + expect(attempt.delivery_state).toBe('succeeded'); + expect(attempt.attempt_count).toBe(1); + expect(attempt.next_retry_at).toBeNull(); + expect(attempt.response_status_code).toBe(200); + + const encodedEmail = encodeURIComponent(user.google_user_email); + expect(fetchMock).toHaveBeenCalledWith( + `https://app.referralsaasquatch.com/api/v1/tenant-alias/open/account/${encodedEmail}/user/${encodedEmail}`, + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + Authorization: + 'Basic ' + + Buffer.from('impact-advocate-account-sid:impact-advocate-auth-token').toString( + 'base64' + ), + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + }) + ); + const requestBody = fetchMock.mock.calls[0]?.[1]?.body; + expect(typeof requestBody).toBe('string'); + expect(JSON.parse(String(requestBody))).toEqual({ + id: user.google_user_email, + accountId: user.google_user_email, + email: user.google_user_email, + cookies: 'sq-cookie', + locale: 'en_US', + countryCode: 'US', + }); + }); + + it('keeps transient failures retryable until a later dispatch succeeds', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce(new Response('upstream unavailable', { status: 503 })) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'retrying@example.com', + normalized_email: 'retrying@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch: { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'ref-code', + rsShareMedium: null, + rsEngagementMedium: null, + landingPath: '/get-started', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + }); + + const firstSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(firstSummary).toEqual({ + claimed: 1, + delivered: 0, + retried: 1, + failed: 0, + }); + + const [afterFirstAttempt] = await db.select().from(impact_advocate_registration_attempts); + expect(afterFirstAttempt.delivery_state).toBe('failed'); + expect(afterFirstAttempt.next_retry_at).toBeTruthy(); + + const [retryingParticipant] = await db.select().from(impact_advocate_participants); + expect(retryingParticipant.registration_state).toBe('retrying'); + + await db + .update(impact_advocate_registration_attempts) + .set({ next_retry_at: '2020-01-01T00:00:00.000Z' }) + .where(eq(impact_advocate_registration_attempts.id, afterFirstAttempt.id)); + + const secondSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(secondSummary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + const [registeredParticipant] = await db.select().from(impact_advocate_participants); + expect(registeredParticipant.registration_state).toBe('registered'); + }); + + it('does not regress a registered participant when the same referral touch is queued again', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ participantId: 'impact-participant-1' }), { status: 200 }) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'already-registered@example.com', + normalized_email: 'already-registered@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + const referralTouch = { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/get-started?_saasquatch=sq-cookie', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + } as const; + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch, + locale: 'en-US', + countryCode: 'US', + }); + + const firstSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(firstSummary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + const [registeredParticipant] = await db.select().from(impact_advocate_participants); + expect(registeredParticipant.registration_state).toBe('registered'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch, + locale: 'en-US', + countryCode: 'US', + }); + + const participants = await db.select().from(impact_advocate_participants); + expect(participants).toHaveLength(1); + expect(participants[0]?.registration_state).toBe('registered'); + + const attempts = await db.select().from(impact_advocate_registration_attempts); + expect(attempts).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('marks 4xx failures terminal, logs them, and does not retry unchanged payloads', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue(new Response('bad request', { status: 400 })); + global.fetch = fetchMock; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const user = await insertTestUser({ + google_user_email: 'terminal@example.com', + normalized_email: 'terminal@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch: { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: null, + rsShareMedium: null, + rsEngagementMedium: null, + landingPath: '/get-started', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + }); + + const firstSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(firstSummary).toEqual({ + claimed: 1, + delivered: 0, + retried: 0, + failed: 1, + }); + + const [participant] = await db.select().from(impact_advocate_participants); + expect(participant.registration_state).toBe('failed'); + expect(participant.last_error_code).toBe('http_4xx'); + + const secondSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(secondSummary).toEqual({ + claimed: 0, + delivered: 0, + retried: 0, + failed: 0, + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[impact-referral] Impact Advocate participant registration failed permanently', + expect.objectContaining({ + userId: user.id, + statusCode: 400, + failureKind: 'http_4xx', + }) + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it( + 'leaves opaque_referral_identifier untouched when another participant ' + + 'already holds the SaaSquatch code', + async () => { + // Existing participant on a *different* user already holds the code. + // The unique constraint on opaque_referral_identifier means we must not + // try to write the same code on a second participant — doing so would + // roll back the success transaction and the cron would loop forever. + const incumbent = await insertTestUser({ + google_user_email: 'incumbent@example.com', + normalized_email: 'incumbent@example.com', + }); + await db.insert(impact_advocate_participants).values({ + user_id: incumbent.id, + advocate_id: incumbent.google_user_email, + advocate_account_id: incumbent.google_user_email, + opaque_referral_identifier: 'COLLIDING_CODE', + registration_state: 'registered', + }); + + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-hash-id-other', + email: 'other@example.com', + referralCodes: { '51699': 'COLLIDING_CODE' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const newUser = await insertTestUser({ + google_user_email: 'other@example.com', + normalized_email: 'other@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user: newUser, + referralTouch: { + opaqueTrackingValue: 'sq-cookie-other', + trackingValueLength: 15, + isTrackingValueAccepted: true, + rsCode: 'ref-other', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/get-started?_saasquatch=sq-cookie-other', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + locale: 'en-US', + countryCode: 'US', + }); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + // The new participant is registered but does NOT receive the colliding + // code; the incumbent keeps it. + const newParticipant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, newUser.id), + }); + expect(newParticipant?.registration_state).toBe('registered'); + expect(newParticipant?.opaque_referral_identifier).toBeNull(); + + const incumbentParticipant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, incumbent.id), + }); + expect(incumbentParticipant?.opaque_referral_identifier).toBe('COLLIDING_CODE'); + } + ); + + describe('queueImpactAdvocateSelfRegistration', () => { + it('queues an Upsert User attempt with empty cookies and persists the SaaSquatch code on dispatch', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-self-id', + email: 'advocate@example.com', + referralCodes: { '51699': 'ADVOCATE7777' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'advocate@example.com', + normalized_email: 'advocate@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateSelfRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateSelfRegistration({ + user, + locale: 'en-US', + countryCode: 'US', + }); + + // Attempt was queued without a cookie value. + const [queued] = await db.select().from(impact_advocate_registration_attempts); + expect(queued.delivery_state).toBe('queued'); + expect(queued.opaque_cookie_value).toBeNull(); + expect(queued.cookie_value_length).toBe(0); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ claimed: 1, delivered: 1, retried: 0, failed: 0 }); + + // Body sent over the wire has empty cookies and locale normalised. + const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(requestBody).toEqual({ + id: 'advocate@example.com', + accountId: 'advocate@example.com', + email: 'advocate@example.com', + cookies: '', + locale: 'en_US', + countryCode: 'US', + }); + + // Participant now carries the SaaSquatch code so future referee touches + // resolve back to this user. + const participant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, user.id), + }); + expect(participant?.registration_state).toBe('registered'); + expect(participant?.opaque_referral_identifier).toBe('ADVOCATE7777'); + }); + + it('is idempotent across repeat calls (deduped by user id)', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-id', + email: 'advocate@example.com', + referralCodes: { '51699': 'ADVOCATE7777' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'advocate@example.com', + normalized_email: 'advocate@example.com', + }); + + const { queueImpactAdvocateSelfRegistration } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateSelfRegistration({ user }); + await queueImpactAdvocateSelfRegistration({ user }); + await queueImpactAdvocateSelfRegistration({ user }); + + const attempts = await db.select().from(impact_advocate_registration_attempts); + expect(attempts).toHaveLength(1); + }); + + it('skips queueing once the participant is already registered with a code', async () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'advocate@example.com', + normalized_email: 'advocate@example.com', + }); + // Pretend SaaSquatch has already registered them and we have the code. + await db.insert(impact_advocate_participants).values({ + user_id: user.id, + advocate_id: user.google_user_email, + advocate_account_id: user.google_user_email, + opaque_referral_identifier: 'ADVOCATE7777', + registration_state: 'registered', + registered_at: new Date('2026-04-01T00:00:00.000Z').toISOString(), + }); + + const { queueImpactAdvocateSelfRegistration } = await import('@/lib/impact-referral'); + await queueImpactAdvocateSelfRegistration({ user }); + + const attempts = await db.select().from(impact_advocate_registration_attempts); + expect(attempts).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts new file mode 100644 index 0000000000..a80008240c --- /dev/null +++ b/apps/web/src/lib/impact-referral.ts @@ -0,0 +1,789 @@ +import 'server-only'; + +import { createHash } from 'crypto'; +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { + buildImpactAdvocateRegisterParticipantPayload, + extractAdvocateReferralCodeFromUpsertResponse, + getImpactAdvocateProgramId, + isImpactAdvocateConfigured, + sendImpactAdvocateRegisterParticipantPayload, + type ImpactAdvocateRegisterParticipantPayload, +} from '@/lib/impact-advocate'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; +import type { + ParsedImpactAffiliateTouch, + ParsedImpactReferralTouch, +} from '@/lib/impact-referral-utils'; +import { + deleted_user_email_tombstones, + impact_advocate_participants, + impact_advocate_registration_attempts, + kiloclaw_attribution_touches, + type User, +} from '@kilocode/db/schema'; +import { + ImpactAdvocateAttemptDeliveryState, + ImpactAdvocateRegistrationState, + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, +} from '@kilocode/db/schema-types'; +import { and, asc, eq, lte, ne, or, sql } from 'drizzle-orm'; + +type DatabaseClient = typeof db | DrizzleTransaction; + +type AttributionActor = { + userId?: string | null; + anonymousId?: string | null; +}; + +export type ImpactAdvocateRegistrationDispatchSummary = { + claimed: number; + delivered: number; + retried: number; + failed: number; +}; + +const IMPACT_ADVOCATE_REGISTRATION_CLAIM_STALE_MS = 15 * 60 * 1000; + +function getDatabaseClient(database?: DatabaseClient): DatabaseClient { + return database ?? db; +} + +function buildHashedDedupeKey(parts: Array): string { + const normalized = parts.map(part => part?.trim() ?? '').join('|'); + return createHash('sha256').update(normalized, 'utf8').digest('hex'); +} + +function touchMinuteBucket(touchedAt: Date): string { + return touchedAt.toISOString().slice(0, 16); +} + +function touchIdentity(actor: AttributionActor): string { + if (actor.userId) return `user:${actor.userId}`; + if (actor.anonymousId) return `anon:${actor.anonymousId}`; + return 'anonymous:missing'; +} + +function isImpactAdvocateRegisterParticipantPayload( + value: Record | null +): value is ImpactAdvocateRegisterParticipantPayload { + if (!value) { + return false; + } + + // Only assert the SaaSquatch-required fields. Extra keys (e.g. legacy + // `programId` rows persisted before the endpoint fix) are tolerated here + // and stripped at send time by sanitizeRegisterParticipantPayloadForWire. + return ( + typeof value.id === 'string' && + typeof value.accountId === 'string' && + typeof value.email === 'string' && + typeof value.cookies === 'string' && + (value.locale === undefined || typeof value.locale === 'string') && + (value.countryCode === undefined || typeof value.countryCode === 'string') + ); +} + +export function hashNormalizedEmailForDeletionTombstone(normalizedEmail: string): string { + return createHash('sha256').update(normalizedEmail.trim().toLowerCase(), 'utf8').digest('hex'); +} + +export async function recordImpactAffiliateTouch(params: { + database?: DatabaseClient; + userId?: string | null; + anonymousId?: string | null; + touch: ParsedImpactAffiliateTouch; +}): Promise { + const database = getDatabaseClient(params.database); + const dedupeKey = buildHashedDedupeKey([ + touchIdentity(params), + KiloClawAttributionTouchType.Affiliate, + KiloClawAttributionTouchProvider.ImpactPerformance, + params.touch.trackingId, + params.touch.landingPath, + touchMinuteBucket(params.touch.touchedAt), + ]); + + const [insertedTouch] = await database + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: dedupeKey, + anonymous_id: params.anonymousId ?? null, + user_id: params.userId ?? null, + touch_type: KiloClawAttributionTouchType.Affiliate, + provider: KiloClawAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: params.touch.trackingId, + tracking_value_length: params.touch.trackingValueLength, + is_tracking_value_accepted: params.touch.isTrackingValueAccepted, + im_ref: params.touch.trackingId, + landing_path: params.touch.landingPath, + utm_source: params.touch.utmSource, + utm_medium: params.touch.utmMedium, + utm_campaign: params.touch.utmCampaign, + utm_term: params.touch.utmTerm, + utm_content: params.touch.utmContent, + touched_at: params.touch.touchedAt.toISOString(), + expires_at: params.touch.expiresAt.toISOString(), + }) + .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }) + .returning({ id: kiloclaw_attribution_touches.id }); + + logImpactReferralDebug( + insertedTouch + ? 'Recorded Impact affiliate attribution touch' + : 'Impact affiliate touch already existed', + { + userId: params.userId ?? null, + anonymousIdPresent: Boolean(params.anonymousId?.trim()), + touchId: insertedTouch?.id ?? null, + landingPath: params.touch.landingPath, + trackingValueLength: params.touch.trackingValueLength, + isTrackingValueAccepted: params.touch.isTrackingValueAccepted, + } + ); +} + +export async function recordImpactReferralTouch(params: { + database?: DatabaseClient; + userId?: string | null; + anonymousId?: string | null; + touch: ParsedImpactReferralTouch; +}): Promise { + const database = getDatabaseClient(params.database); + const dedupeKey = buildHashedDedupeKey([ + touchIdentity(params), + KiloClawAttributionTouchType.Referral, + KiloClawAttributionTouchProvider.ImpactAdvocate, + params.touch.opaqueTrackingValue, + params.touch.rsCode, + params.touch.landingPath, + touchMinuteBucket(params.touch.touchedAt), + ]); + + const [insertedTouch] = await database + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: dedupeKey, + anonymous_id: params.anonymousId ?? null, + user_id: params.userId ?? null, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: params.touch.opaqueTrackingValue, + tracking_value_length: params.touch.trackingValueLength, + is_tracking_value_accepted: params.touch.isTrackingValueAccepted, + rs_code: params.touch.rsCode, + rs_share_medium: params.touch.rsShareMedium, + rs_engagement_medium: params.touch.rsEngagementMedium, + landing_path: params.touch.landingPath, + utm_source: params.touch.utmSource, + utm_medium: params.touch.utmMedium, + utm_campaign: params.touch.utmCampaign, + utm_term: params.touch.utmTerm, + utm_content: params.touch.utmContent, + touched_at: params.touch.touchedAt.toISOString(), + expires_at: params.touch.expiresAt.toISOString(), + }) + .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }) + .returning({ id: kiloclaw_attribution_touches.id }); + + logImpactReferralDebug( + insertedTouch + ? 'Recorded Impact Advocate referral touch' + : 'Impact Advocate referral touch already existed', + { + userId: params.userId ?? null, + anonymousIdPresent: Boolean(params.anonymousId?.trim()), + touchId: insertedTouch?.id ?? null, + landingPath: params.touch.landingPath, + rsCodePresent: Boolean(params.touch.rsCode?.trim()), + trackingValueLength: params.touch.trackingValueLength, + isTrackingValueAccepted: params.touch.isTrackingValueAccepted, + } + ); +} + +export async function ensureImpactAdvocateParticipantProfile(params: { + database?: DatabaseClient; + user: Pick; + locale?: string | null; + countryCode?: string | null; + opaqueReferralIdentifier?: string | null; +}): Promise<{ id: string }> { + const database = getDatabaseClient(params.database); + + const isConfigured = isImpactAdvocateConfigured(); + + const [insertedParticipant] = await database + .insert(impact_advocate_participants) + .values({ + user_id: params.user.id, + advocate_id: params.user.google_user_email, + advocate_account_id: params.user.google_user_email, + opaque_referral_identifier: params.opaqueReferralIdentifier ?? null, + contact_email: params.user.google_user_email, + locale: params.locale ?? null, + country_code: params.countryCode ?? null, + registration_state: isConfigured + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isConfigured ? null : 'missing_configuration', + last_error_message: isConfigured ? null : 'Impact Advocate configuration is incomplete', + }) + .onConflictDoNothing({ target: [impact_advocate_participants.user_id] }) + .returning({ id: impact_advocate_participants.id }); + + const participant = + insertedParticipant ?? + (await database.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, params.user.id), + columns: { id: true }, + })); + + if (!participant) { + throw new Error(`Impact Advocate participant missing for user ${params.user.id}`); + } + + await database + .update(impact_advocate_participants) + .set({ + advocate_id: params.user.google_user_email, + advocate_account_id: params.user.google_user_email, + contact_email: params.user.google_user_email, + locale: params.locale ?? null, + country_code: params.countryCode ?? null, + ...(params.opaqueReferralIdentifier + ? { opaque_referral_identifier: params.opaqueReferralIdentifier } + : {}), + }) + .where(eq(impact_advocate_participants.id, participant.id)); + + return { id: participant.id }; +} + +export async function queueImpactAdvocateParticipantRegistration(params: { + database?: DatabaseClient; + user: Pick; + referralTouch: ParsedImpactReferralTouch; + locale?: string | null; + countryCode?: string | null; +}): Promise { + if (!params.referralTouch.opaqueTrackingValue) { + logImpactReferralDebug( + 'Skipped Impact Advocate participant registration queue; missing referral cookie value', + { + userId: params.user.id, + landingPath: params.referralTouch.landingPath, + } + ); + return; + } + + const database = getDatabaseClient(params.database); + const payload = buildImpactAdvocateRegisterParticipantPayload({ + user: params.user, + referralCookieValue: params.referralTouch.opaqueTrackingValue, + locale: params.locale, + countryCode: params.countryCode, + }); + const nowIso = new Date().toISOString(); + const isConfigured = isImpactAdvocateConfigured(); + const participant = await ensureImpactAdvocateParticipantProfile({ + database, + user: params.user, + locale: params.locale, + countryCode: params.countryCode, + }); + + const attemptDedupeKey = buildHashedDedupeKey([ + 'impact-advocate-registration', + params.user.id, + params.referralTouch.opaqueTrackingValue, + ]); + + const [insertedAttempt] = await database + .insert(impact_advocate_registration_attempts) + .values({ + participant_id: participant.id, + dedupe_key: attemptDedupeKey, + opaque_cookie_value: params.referralTouch.opaqueTrackingValue, + cookie_value_length: params.referralTouch.trackingValueLength, + delivery_state: isConfigured + ? ImpactAdvocateAttemptDeliveryState.Queued + : ImpactAdvocateAttemptDeliveryState.Failed, + request_payload: payload satisfies Record, + response_payload: isConfigured + ? null + : ({ error: 'missing_configuration' } satisfies Record), + response_status_code: isConfigured ? null : 503, + }) + .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }) + .returning({ id: impact_advocate_registration_attempts.id }); + + logImpactReferralDebug( + insertedAttempt + ? 'Queued Impact Advocate participant registration attempt' + : 'Impact Advocate participant registration attempt already existed', + { + userId: params.user.id, + participantId: participant.id, + attemptId: insertedAttempt?.id ?? null, + impactAdvocateConfigured: isConfigured, + trackingValueLength: params.referralTouch.trackingValueLength, + localePresent: Boolean(params.locale?.trim()), + countryCode: params.countryCode ?? null, + } + ); + + if (!insertedAttempt) { + return; + } + + await database + .update(impact_advocate_participants) + .set({ + registration_state: isConfigured + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isConfigured ? null : 'missing_configuration', + last_error_message: isConfigured ? null : 'Impact Advocate configuration is incomplete', + last_registration_attempt_at: nowIso, + }) + .where(eq(impact_advocate_participants.id, participant.id)); +} + +/** + * Queue an Upsert User attempt for an advocate-only Kilo user — someone who + * has not arrived through a referral cookie themselves but is now actively + * trying to share a referral link (e.g. the user has loaded /claw/refer). + * + * Without this, the only Kilo users with `impact_advocate_participants` rows + * are referees; advocate-only users would have either no row or a row whose + * `opaque_referral_identifier` was a Kilo-side UUID with no relationship to + * the SaaSquatch-issued referral code. That UUID can never match an inbound + * referee touch's `rs_code`, so the conversion lifecycle would resolve + * `referrerUserId = null` and silently undercount attribution on the Kilo + * side. See spec rules 11 and 51. + * + * Idempotent: deduped by user id, so repeated `/claw/refer` visits don't + * stack attempts. The dispatcher (dispatchImpactAdvocateRegistrationAttemptById) + * extracts the SaaSquatch code from the response and writes it to + * `participants.opaque_referral_identifier` exactly the same way as for + * referee registrations. + */ +export async function queueImpactAdvocateSelfRegistration(params: { + database?: DatabaseClient; + user: Pick; + locale?: string | null; + countryCode?: string | null; +}): Promise { + const database = getDatabaseClient(params.database); + const isConfigured = isImpactAdvocateConfigured(); + const nowIso = new Date().toISOString(); + + // Empty cookie envelope — advocate-only users have no inbound attribution. + // SaaSquatch's Verified Access widget creates such users on the fly when + // the JWT identifies them; this server-side mirror produces the same + // outcome and lets us read the referralCodes back from the response. + const payload = buildImpactAdvocateRegisterParticipantPayload({ + user: params.user, + referralCookieValue: '', + locale: params.locale, + countryCode: params.countryCode, + }); + + const participant = await ensureImpactAdvocateParticipantProfile({ + database, + user: params.user, + locale: params.locale, + countryCode: params.countryCode, + }); + + const existing = await database.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.id, participant.id), + columns: { registration_state: true, opaque_referral_identifier: true }, + }); + if ( + existing?.registration_state === ImpactAdvocateRegistrationState.Registered && + existing.opaque_referral_identifier?.trim() + ) { + logImpactReferralDebug( + 'Skipped Impact Advocate self-registration; participant already registered with code', + { + userId: params.user.id, + participantId: participant.id, + } + ); + return; + } + + const attemptDedupeKey = buildHashedDedupeKey([ + 'impact-advocate-self-registration', + params.user.id, + ]); + + const [insertedAttempt] = await database + .insert(impact_advocate_registration_attempts) + .values({ + participant_id: participant.id, + dedupe_key: attemptDedupeKey, + opaque_cookie_value: null, + cookie_value_length: 0, + delivery_state: isConfigured + ? ImpactAdvocateAttemptDeliveryState.Queued + : ImpactAdvocateAttemptDeliveryState.Failed, + request_payload: payload satisfies Record, + response_payload: isConfigured + ? null + : ({ error: 'missing_configuration' } satisfies Record), + response_status_code: isConfigured ? null : 503, + }) + .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }) + .returning({ id: impact_advocate_registration_attempts.id }); + + logImpactReferralDebug( + insertedAttempt + ? 'Queued Impact Advocate self-registration attempt' + : 'Impact Advocate self-registration attempt already existed', + { + userId: params.user.id, + participantId: participant.id, + attemptId: insertedAttempt?.id ?? null, + impactAdvocateConfigured: isConfigured, + localePresent: Boolean(params.locale?.trim()), + countryCode: params.countryCode ?? null, + } + ); + + if (!insertedAttempt) { + return; + } + + await database + .update(impact_advocate_participants) + .set({ + registration_state: isConfigured + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isConfigured ? null : 'missing_configuration', + last_error_message: isConfigured ? null : 'Impact Advocate configuration is incomplete', + last_registration_attempt_at: nowIso, + }) + .where(eq(impact_advocate_participants.id, participant.id)); +} + +export async function createDeletedUserEmailTombstone(params: { + database?: DatabaseClient; + normalizedEmail: string | null; +}): Promise { + if (!params.normalizedEmail) { + return; + } + + const database = getDatabaseClient(params.database); + await database + .insert(deleted_user_email_tombstones) + .values({ + normalized_email_hash: hashNormalizedEmailForDeletionTombstone(params.normalizedEmail), + }) + .onConflictDoNothing({ target: [deleted_user_email_tombstones.normalized_email_hash] }); +} + +export function localeFromHeaders(headers?: Headers): string | null { + const acceptLanguage = headers?.get('accept-language')?.trim(); + if (!acceptLanguage) return null; + return acceptLanguage.split(',')[0]?.trim() || null; +} + +export function countryCodeFromHeaders(headers?: Headers): string | null { + const countryCode = headers?.get('x-vercel-ip-country')?.trim(); + return countryCode ? countryCode : null; +} + +function registrationBackoffDelayMs(attemptCount: number): number { + const maxDelayMs = 60 * 60 * 1000; + const initialDelayMs = 60 * 1000; + return Math.min(initialDelayMs * 2 ** Math.max(attemptCount, 0), maxDelayMs); +} + +function nextRegistrationRetryAt(attemptCount: number): string { + return new Date(Date.now() + registrationBackoffDelayMs(attemptCount)).toISOString(); +} + +async function dispatchImpactAdvocateRegistrationAttemptById( + attemptId: string +): Promise<'delivered' | 'retried' | 'failed'> { + const attempt = await db.query.impact_advocate_registration_attempts.findFirst({ + where: eq(impact_advocate_registration_attempts.id, attemptId), + }); + if (!attempt) { + return 'failed'; + } + + const participant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.id, attempt.participant_id), + }); + if (!participant) { + return 'failed'; + } + + const payload = attempt.request_payload; + if (!isImpactAdvocateRegisterParticipantPayload(payload)) { + const failedAt = new Date().toISOString(); + await db.transaction(async tx => { + await tx + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Failed, + attempt_count: attempt.attempt_count + 1, + next_retry_at: null, + claimed_at: failedAt, + response_payload: { + error: 'missing_request_payload', + } satisfies Record, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + await tx + .update(impact_advocate_participants) + .set({ + registration_state: ImpactAdvocateRegistrationState.Failed, + last_error_code: 'missing_request_payload', + last_error_message: 'Impact Advocate registration attempt is missing request_payload', + last_registration_attempt_at: failedAt, + }) + .where(eq(impact_advocate_participants.id, participant.id)); + }); + return 'failed'; + } + + const sendingAt = new Date().toISOString(); + await db + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Sending, + claimed_at: sendingAt, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + logImpactReferralDebug('Dispatching Impact Advocate participant registration attempt', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + attemptCount: attempt.attempt_count, + }); + + const result = await sendImpactAdvocateRegisterParticipantPayload(payload); + const attemptCount = attempt.attempt_count + 1; + const completedAt = new Date().toISOString(); + + logImpactReferralDebug('Impact Advocate participant registration dispatch result', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + ok: result.ok, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.statusCode ?? null, + }); + + if (result.ok) { + // Pull the SaaSquatch-generated referral code out of the response so the + // participant becomes discoverable as an Advocate. Without this, every + // future referee touch carrying this user's rsCode would resolve + // referrerUserId=null and the rewards lifecycle would silently undercount + // attribution on the Kilo side. The unique constraint on + // opaque_referral_identifier means we have to pre-check for a collision + // (vanishingly unlikely — SaaSquatch issues unique codes per tenant — but + // a violation here would otherwise roll back the whole success transaction + // and put us in a retry loop). + const programId = getImpactAdvocateProgramId(); + const advocateCode = extractAdvocateReferralCodeFromUpsertResponse( + result.responseBody, + programId + ); + + let advocateCodeToPersist: string | null = null; + if (advocateCode) { + const conflicting = await db.query.impact_advocate_participants.findFirst({ + where: and( + eq(impact_advocate_participants.opaque_referral_identifier, advocateCode), + ne(impact_advocate_participants.id, participant.id) + ), + columns: { id: true, user_id: true }, + }); + if (conflicting) { + logImpactReferralDebug( + 'Skipped persisting Impact Advocate referral code due to existing holder', + { + participantId: participant.id, + conflictingParticipantId: conflicting.id, + conflictingUserId: conflicting.user_id, + programId, + } + ); + } else { + advocateCodeToPersist = advocateCode; + } + } + + logImpactReferralDebug('Parsed Impact Advocate referral code from upsert response', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + programId, + advocateCodePresent: Boolean(advocateCode), + advocateCodePersisted: Boolean(advocateCodeToPersist), + }); + + await db.transaction(async tx => { + await tx + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Succeeded, + attempt_count: attemptCount, + next_retry_at: null, + claimed_at: completedAt, + response_status_code: result.statusCode ?? null, + response_payload: { + responseBody: result.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + await tx + .update(impact_advocate_participants) + .set({ + registration_state: ImpactAdvocateRegistrationState.Registered, + registered_at: completedAt, + last_registration_attempt_at: completedAt, + last_error_code: null, + last_error_message: null, + ...(advocateCodeToPersist ? { opaque_referral_identifier: advocateCodeToPersist } : {}), + }) + .where(eq(impact_advocate_participants.id, participant.id)); + }); + return 'delivered'; + } + + const isTerminalFailure = result.failureKind === 'http_4xx'; + if (isTerminalFailure) { + console.error('[impact-referral] Impact Advocate participant registration failed permanently', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + statusCode: result.statusCode ?? null, + failureKind: result.failureKind, + }); + } + + await db.transaction(async tx => { + await tx + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Failed, + attempt_count: attemptCount, + next_retry_at: isTerminalFailure ? null : nextRegistrationRetryAt(attemptCount), + claimed_at: completedAt, + response_status_code: result.statusCode ?? null, + response_payload: { + failureKind: result.failureKind, + responseBody: result.responseBody ?? null, + error: result.error ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + await tx + .update(impact_advocate_participants) + .set({ + registration_state: isTerminalFailure + ? ImpactAdvocateRegistrationState.Failed + : ImpactAdvocateRegistrationState.Retrying, + last_registration_attempt_at: completedAt, + last_error_code: isTerminalFailure ? 'http_4xx' : result.failureKind, + last_error_message: + result.error ?? + (result.statusCode + ? `Impact Advocate registration failed with status ${result.statusCode}` + : 'Impact Advocate registration failed'), + }) + .where(eq(impact_advocate_participants.id, participant.id)); + }); + + return isTerminalFailure ? 'failed' : 'retried'; +} + +export async function dispatchQueuedImpactAdvocateRegistrationAttempts(params?: { + limit?: number; +}): Promise { + const limit = params?.limit ?? 100; + const now = Date.now(); + const nowIso = new Date(now).toISOString(); + const staleClaimedAt = new Date(now - IMPACT_ADVOCATE_REGISTRATION_CLAIM_STALE_MS).toISOString(); + const rows = await db + .select({ id: impact_advocate_registration_attempts.id }) + .from(impact_advocate_registration_attempts) + .innerJoin( + impact_advocate_participants, + eq(impact_advocate_participants.id, impact_advocate_registration_attempts.participant_id) + ) + .where( + or( + eq( + impact_advocate_registration_attempts.delivery_state, + ImpactAdvocateAttemptDeliveryState.Queued + ), + and( + eq( + impact_advocate_participants.registration_state, + ImpactAdvocateRegistrationState.Retrying + ), + eq( + impact_advocate_registration_attempts.delivery_state, + ImpactAdvocateAttemptDeliveryState.Failed + ), + or( + sql`${impact_advocate_registration_attempts.next_retry_at} IS NULL`, + lte(impact_advocate_registration_attempts.next_retry_at, nowIso) + ) + ), + and( + eq( + impact_advocate_registration_attempts.delivery_state, + ImpactAdvocateAttemptDeliveryState.Sending + ), + lte(impact_advocate_registration_attempts.claimed_at, staleClaimedAt) + ) + ) + ) + .orderBy( + asc(impact_advocate_registration_attempts.created_at), + asc(impact_advocate_registration_attempts.id) + ) + .limit(limit); + + const summary: ImpactAdvocateRegistrationDispatchSummary = { + claimed: rows.length, + delivered: 0, + retried: 0, + failed: 0, + }; + + logImpactReferralDebug('Claimed queued Impact Advocate participant registration attempts', { + claimed: summary.claimed, + limit, + }); + + for (const row of rows) { + const outcome = await dispatchImpactAdvocateRegistrationAttemptById(row.id); + if (outcome === 'delivered') { + summary.delivered++; + } else if (outcome === 'retried') { + summary.retried++; + } else { + summary.failed++; + } + } + + return summary; +} diff --git a/apps/web/src/lib/impact.ts b/apps/web/src/lib/impact.ts index d5d150fcaf..1b3e7fcaea 100644 --- a/apps/web/src/lib/impact.ts +++ b/apps/web/src/lib/impact.ts @@ -2,6 +2,7 @@ import 'server-only'; import { createHash } from 'crypto'; import { IMPACT_ACCOUNT_SID, IMPACT_AUTH_TOKEN, IMPACT_CAMPAIGN_ID } from '@/lib/config.server'; +import { logImpactReferralDebug, truncateForLog } from '@/lib/impact-debug'; const IMPACT_REVERSAL_DISPOSITION_CODE = 'REJECTED'; @@ -546,13 +547,38 @@ function getNormalizedStatus(value: unknown): string | null { export async function sendImpactConversionPayload( payload: ImpactConversionPayload ): Promise { + const conversionPath = `/Advertisers/${IMPACT_ACCOUNT_SID}/Conversions`; + logImpactReferralDebug('Sending Impact conversion payload', { + actionTrackerId: payload.ActionTrackerId, + orderId: payload.OrderId, + url: buildImpactUrl(conversionPath), + clickIdPresent: Boolean(payload.ClickId?.trim()), + customerIdPresent: Boolean(payload.CustomerId?.trim()), + customerEmailHashPresent: Boolean(payload.CustomerEmail?.trim()), + amount: payload.ItemSubTotal1 ?? null, + currencyCode: payload.CurrencyCode ?? null, + itemCategory: payload.ItemCategory1 ?? null, + impactConfigured: isImpactConfigured(), + }); + const result = await sendImpactRequest({ method: 'POST', - path: `/Advertisers/${IMPACT_ACCOUNT_SID}/Conversions`, + path: conversionPath, body: JSON.stringify(payload), contentType: 'application/json', }); + logImpactReferralDebug('Impact conversion payload result', { + actionTrackerId: payload.ActionTrackerId, + orderId: payload.OrderId, + ok: result.ok, + delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + responseBody: result.ok ? null : truncateForLog(result.responseBody ?? null), + error: result.ok ? null : (result.error ?? null), + }); + if ( result.ok && payload.ActionTrackerId === IMPACT_ACTION_TRACKER_IDS.sale && @@ -560,6 +586,12 @@ export async function sendImpactConversionPayload( result.delivery !== 'immediate' && result.delivery !== 'queued' ) { + logImpactReferralDebug('Impact sale response missing required action mapping', { + actionTrackerId: payload.ActionTrackerId, + orderId: payload.OrderId, + delivery: result.delivery ?? null, + }); + return { ok: false, failureKind: 'submission_failed', @@ -574,10 +606,23 @@ export async function sendImpactConversionPayload( export async function resolveImpactSubmissionUri( submissionUri: string ): Promise { + logImpactReferralDebug('Resolving Impact submission URI', { + submissionUri, + url: buildImpactUrl(submissionUri), + impactConfigured: isImpactConfigured(), + }); const result = await sendImpactRequest({ method: 'GET', path: submissionUri, }); + logImpactReferralDebug('Impact submission URI resolution raw result', { + submissionUri, + ok: result.ok, + delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + responseBody: truncateForLog(result.ok ? result.responseBody : result.responseBody), + }); if (!result.ok) { return result.failureKind === 'network' @@ -645,17 +690,33 @@ export async function resolveImpactSubmissionUri( export async function reverseImpactAction(params: { actionId: string; }): Promise { + logImpactReferralDebug('Sending Impact action reversal', { + actionId: params.actionId, + dispositionCode: IMPACT_REVERSAL_DISPOSITION_CODE, + impactConfigured: isImpactConfigured(), + }); + const formData = new URLSearchParams({ ActionId: params.actionId, DispositionCode: IMPACT_REVERSAL_DISPOSITION_CODE, }); - return await sendImpactRequest({ + const result = await sendImpactRequest({ method: 'DELETE', path: `/Advertisers/${IMPACT_ACCOUNT_SID}/Actions`, body: formData.toString(), contentType: 'application/x-www-form-urlencoded', }); + + logImpactReferralDebug('Impact action reversal result', { + actionId: params.actionId, + ok: result.ok, + delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + }); + + return result; } function throwIfImpactDispatchFailed(eventName: string, result: ImpactDispatchResult): void { diff --git a/apps/web/src/lib/kiloclaw-referrals.test.ts b/apps/web/src/lib/kiloclaw-referrals.test.ts new file mode 100644 index 0000000000..f268f9b070 --- /dev/null +++ b/apps/web/src/lib/kiloclaw-referrals.test.ts @@ -0,0 +1,1593 @@ +import { randomUUID } from 'crypto'; +import { eq, sql } from 'drizzle-orm'; + +jest.mock('@/lib/impact', () => { + const actual = jest.requireActual('@/lib/impact'); + return { + ...actual, + isImpactConfigured: jest.fn(() => true), + sendImpactConversionPayload: jest.fn(async () => ({ ok: true, delivery: 'accepted' })), + reverseImpactAction: jest.fn(async () => ({ ok: true, delivery: 'accepted' })), + }; +}); + +jest.mock('@/lib/impact-advocate', () => { + const actual = jest.requireActual('@/lib/impact-advocate'); + return { + ...actual, + isImpactAdvocateConfigured: jest.fn(() => true), + sendImpactAdvocateRewardLookupPayload: jest.fn(async () => ({ + ok: true, + statusCode: 200, + responseBody: JSON.stringify({ rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }] }), + rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }], + })), + sendImpactAdvocateRewardRedemptionPayload: jest.fn(async () => ({ + ok: true, + statusCode: 200, + responseBody: '{}', + })), + }; +}); + +jest.mock('@/lib/stripe-client', () => ({ + client: { + subscriptions: { + update: jest.fn(async () => ({})), + }, + }, +})); + +import { db } from '@/lib/drizzle'; +import { + credit_transactions, + impact_advocate_participants, + impact_advocate_reward_redemptions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_instances, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, + kilocode_users, + referral_codes, + user_affiliate_attributions, + type KiloClawAttributionTouch, +} from '@kilocode/db/schema'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + dispatchQueuedImpactAdvocateRewardRedemptions, + markPersonalKiloClawReferralPaymentAdverse, + processPersonalKiloClawPaidConversion, + processQueuedKiloClawReferralRewards, + resolveWinningAttributionTouch, +} from '@/lib/kiloclaw-referrals'; +import { isImpactConfigured, reverseImpactAction, sendImpactConversionPayload } from '@/lib/impact'; +import { + isImpactAdvocateConfigured, + sendImpactAdvocateRewardLookupPayload, + sendImpactAdvocateRewardRedemptionPayload, +} from '@/lib/impact-advocate'; +import { client as stripeClient } from '@/lib/stripe-client'; + +const mockIsImpactConfigured = jest.mocked(isImpactConfigured); +const mockIsImpactAdvocateConfigured = jest.mocked(isImpactAdvocateConfigured); +const mockSendImpactConversionPayload = jest.mocked(sendImpactConversionPayload); +const mockSendImpactAdvocateRewardLookupPayload = jest.mocked( + sendImpactAdvocateRewardLookupPayload +); +const mockSendImpactAdvocateRewardRedemptionPayload = jest.mocked( + sendImpactAdvocateRewardRedemptionPayload +); +const mockReverseImpactAction = jest.mocked(reverseImpactAction); +const mockStripeSubscriptionUpdate = jest.mocked(stripeClient.subscriptions.update); + +function makeTouch( + overrides: Partial & Pick +): KiloClawAttributionTouch { + const touchedAt = overrides.touched_at ?? '2026-04-01T00:00:00.000Z'; + return { + id: overrides.id ?? randomUUID(), + dedupe_key: overrides.dedupe_key ?? randomUUID(), + anonymous_id: overrides.anonymous_id ?? null, + user_id: overrides.user_id ?? 'user_123', + touch_type: overrides.touch_type, + provider: + overrides.provider ?? + (overrides.touch_type === 'referral' ? 'impact_advocate' : 'impact_performance'), + opaque_tracking_value: overrides.opaque_tracking_value ?? 'opaque-value', + tracking_value_length: overrides.tracking_value_length ?? 12, + is_tracking_value_accepted: overrides.is_tracking_value_accepted ?? true, + rs_code: overrides.rs_code ?? null, + rs_share_medium: overrides.rs_share_medium ?? null, + rs_engagement_medium: overrides.rs_engagement_medium ?? null, + im_ref: overrides.im_ref ?? null, + landing_path: overrides.landing_path ?? null, + utm_source: overrides.utm_source ?? null, + utm_medium: overrides.utm_medium ?? null, + utm_campaign: overrides.utm_campaign ?? null, + utm_term: overrides.utm_term ?? null, + utm_content: overrides.utm_content ?? null, + touched_at: touchedAt, + expires_at: overrides.expires_at ?? '2026-05-01T00:00:00.000Z', + sale_attributed_at: overrides.sale_attributed_at ?? null, + created_at: overrides.created_at ?? touchedAt, + }; +} + +async function insertActivePersonalSubscription( + userId: string, + overrides?: Partial & { + organizationId?: string | null; + } +): Promise<{ subscriptionId: string; instanceId: string }> { + const [instance] = await db + .insert(kiloclaw_instances) + .values({ + user_id: userId, + sandbox_id: `sandbox-${userId}`, + organization_id: overrides?.organizationId ?? null, + }) + .returning({ id: kiloclaw_instances.id }); + + const [subscription] = await db + .insert(kiloclaw_subscriptions) + .values({ + user_id: userId, + instance_id: instance.id, + payment_source: 'credits', + plan: 'standard', + status: 'active', + current_period_start: '2026-04-01T00:00:00.000Z', + current_period_end: '2026-05-01T00:00:00.000Z', + credit_renewal_at: '2026-05-01T00:00:00.000Z', + cancel_at_period_end: false, + ...overrides, + }) + .returning({ id: kiloclaw_subscriptions.id }); + + return { + subscriptionId: subscription.id, + instanceId: instance.id, + }; +} + +async function insertImpactAdvocateParticipant(userId: string, opaqueReferralIdentifier?: string) { + const identifier = opaqueReferralIdentifier ?? randomUUID(); + await db.insert(impact_advocate_participants).values({ + user_id: userId, + advocate_id: userId, + advocate_account_id: userId, + opaque_referral_identifier: identifier, + contact_email: `${userId}@example.com`, + registration_state: 'registered', + registered_at: '2026-03-01T00:00:00.000Z', + }); + return identifier; +} + +async function insertAppliedReferralRewardForUser(userId: string): Promise { + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: userId, + referrer_user_id: null, + winning_touch_type: 'none', + source_payment_id: `reward-redemption-test:${randomUUID()}`, + qualified: true, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + if (!conversion) throw new Error('Failed to insert referral conversion'); + + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: userId, + beneficiary_role: 'referee', + outcome: 'granted', + months_granted: 1, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + + if (!decision) throw new Error('Failed to insert referral reward decision'); + + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: userId, + beneficiary_role: 'referee', + months_granted: 1, + status: 'applied', + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }) + .returning({ id: kiloclaw_referral_rewards.id }); + + if (!reward) throw new Error('Failed to insert referral reward'); + + return reward.id; +} + +describe('kiloclaw referrals', () => { + afterEach(async () => { + jest.clearAllMocks(); + mockIsImpactConfigured.mockReturnValue(true); + mockIsImpactAdvocateConfigured.mockReturnValue(true); + mockSendImpactAdvocateRewardLookupPayload.mockResolvedValue({ + ok: true, + statusCode: 200, + responseBody: JSON.stringify({ rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }] }), + rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }], + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValue({ + ok: true, + statusCode: 200, + responseBody: '{}', + }); + await db.delete(impact_conversion_reports).where(sql`true`); + await db.delete(impact_advocate_reward_redemptions).where(sql`true`); + await db.delete(kiloclaw_referral_reward_applications).where(sql`true`); + await db.delete(kiloclaw_referral_rewards).where(sql`true`); + await db.delete(kiloclaw_referral_reward_decisions).where(sql`true`); + await db.delete(kiloclaw_referral_conversions).where(sql`true`); + await db.delete(user_affiliate_attributions).where(sql`true`); + await db.delete(kiloclaw_attribution_touches).where(sql`true`); + await db.delete(credit_transactions).where(sql`true`); + await db.delete(kiloclaw_subscription_change_log).where(sql`true`); + await db.delete(kiloclaw_subscriptions).where(sql`true`); + await db.delete(kiloclaw_instances).where(sql`true`); + await db.delete(impact_advocate_participants).where(sql`true`); + await db.delete(referral_codes).where(sql`true`); + await db.delete(kilocode_users).where(sql`true`); + }); + + describe('dispatchQueuedImpactAdvocateRewardRedemptions', () => { + it('does not treat already-redeemed Impact responses as success before this row has selected the reward', async () => { + const user = await insertTestUser({ + google_user_email: 'first-already-redeemed@example.com', + normalized_email: 'first-already-redeemed@example.com', + }); + const rewardId = await insertAppliedReferralRewardForUser(user.id); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: rewardId, + dedupe_key: `first-already-redeemed:${rewardId}`, + beneficiary_user_id: user.id, + state: 'queued', + request_payload: { + lookup: { + accountId: user.google_user_email, + userId: user.google_user_email, + rewardTypeFilter: 'CREDIT', + }, + redemption: { amount: 1, unit: 'free-months' }, + }, + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValueOnce({ + ok: false, + failureKind: 'http_4xx', + statusCode: 400, + responseBody: 'Reward already redeemed', + }); + + const summary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + + expect(summary).toEqual({ claimed: 1, redeemed: 0, retried: 0, failed: 1 }); + const [redemption] = await db.select().from(impact_advocate_reward_redemptions); + expect(redemption).toEqual( + expect.objectContaining({ + state: 'failed', + impact_reward_id: 'impact-reward-123', + response_status_code: 400, + }) + ); + }); + + it('treats already-redeemed Impact responses as idempotent success for a previously selected reward', async () => { + const user = await insertTestUser({ + google_user_email: 'retry-already-redeemed@example.com', + normalized_email: 'retry-already-redeemed@example.com', + }); + const rewardId = await insertAppliedReferralRewardForUser(user.id); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: rewardId, + dedupe_key: `retry-already-redeemed:${rewardId}`, + beneficiary_user_id: user.id, + state: 'queued', + impact_reward_id: 'impact-reward-123', + request_payload: { + lookup: { + accountId: user.google_user_email, + userId: user.google_user_email, + rewardTypeFilter: 'CREDIT', + }, + redemption: { amount: 1, unit: 'free-months' }, + }, + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValueOnce({ + ok: false, + failureKind: 'http_4xx', + statusCode: 400, + responseBody: 'Reward already redeemed', + }); + + const summary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + + expect(summary).toEqual({ claimed: 1, redeemed: 1, retried: 0, failed: 0 }); + const [redemption] = await db.select().from(impact_advocate_reward_redemptions); + expect(redemption).toEqual( + expect.objectContaining({ + state: 'redeemed', + impact_reward_id: 'impact-reward-123', + response_status_code: 400, + redeem_response_payload: expect.objectContaining({ alreadyRedeemed: true }), + }) + ); + }); + }); + + describe('resolveWinningAttributionTouch', () => { + const convertedAt = new Date('2026-04-10T00:00:00.000Z'); + + it('prefers referral over an unsold affiliate touch', () => { + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + touched_at: '2026-04-01T00:00:00.000Z', + im_ref: 'im-ref', + }); + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + touched_at: '2026-04-02T00:00:00.000Z', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch, referralTouch], convertedAt }) + ).toMatchObject({ winner: 'referral', referralTouch: { id: 'referral-touch' } }); + }); + + it('preserves affiliate when it had already been sale-attributed before the referral touch', () => { + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + touched_at: '2026-04-01T00:00:00.000Z', + sale_attributed_at: '2026-04-01T12:00:00.000Z', + im_ref: 'im-ref', + }); + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + touched_at: '2026-04-02T00:00:00.000Z', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch, referralTouch], convertedAt }) + ).toMatchObject({ winner: 'affiliate', affiliateTouch: { id: 'affiliate-touch' } }); + }); + + it('keeps referral priority when the referral touch happened first', () => { + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + touched_at: '2026-04-01T00:00:00.000Z', + rs_code: 'ref-code', + }); + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + touched_at: '2026-04-02T00:00:00.000Z', + im_ref: 'im-ref', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch, referralTouch], convertedAt }) + ).toMatchObject({ winner: 'referral', referralTouch: { id: 'referral-touch' } }); + }); + + it('falls back to affiliate when no valid referral exists', () => { + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + im_ref: 'im-ref', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch], convertedAt }) + ).toMatchObject({ winner: 'affiliate', affiliateTouch: { id: 'affiliate-touch' } }); + }); + + it('falls back to referral when no affiliate exists', () => { + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ touches: [referralTouch], convertedAt }) + ).toMatchObject({ winner: 'referral', referralTouch: { id: 'referral-touch' } }); + }); + + it('returns none when all touches are expired or invalid', () => { + const expiredAffiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + im_ref: 'im-ref', + expires_at: '2026-04-05T00:00:00.000Z', + }); + const invalidReferralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + rs_code: 'ref-code', + opaque_tracking_value: null, + is_tracking_value_accepted: false, + }); + + expect( + resolveWinningAttributionTouch({ + touches: [expiredAffiliateTouch, invalidReferralTouch], + convertedAt, + }) + ).toEqual({ + winner: 'none', + affiliateTouch: null, + referralTouch: null, + }); + }); + }); + + describe('processPersonalKiloClawPaidConversion', () => { + it('records affiliate-winning first paid conversions and marks the touch as sale-attributed', async () => { + const user = await insertTestUser({ + google_user_email: 'affiliate-winner@example.com', + normalized_email: 'affiliate-winner@example.com', + }); + const sourcePaymentId = 'kiloclaw-subscription:instance-a:2026-04'; + + await insertActivePersonalSubscription(user.id); + await db.insert(credit_transactions).values({ + kilo_user_id: user.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + const affiliateTouchId = '11111111-1111-4111-8111-111111111111'; + await db.insert(kiloclaw_attribution_touches).values({ + id: affiliateTouchId, + dedupe_key: 'affiliate-touch', + user_id: user.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'im-ref-123', + tracking_value_length: 10, + is_tracking_value_accepted: true, + im_ref: 'im-ref-123', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-01T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: user.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: expect.any(String), + disqualificationReason: 'referral_affiliate_won', + }); + + const [touch] = await db + .select() + .from(kiloclaw_attribution_touches) + .where(eq(kiloclaw_attribution_touches.id, affiliateTouchId)); + expect(touch.sale_attributed_at).toBeTruthy(); + expect(mockSendImpactConversionPayload).not.toHaveBeenCalled(); + }); + + it('records referral-winning first paid conversions, grants both sides, and queues impact reporting', async () => { + const referrer = await insertTestUser({ + google_user_email: 'referrer@example.com', + normalized_email: 'referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'referee@example.com', + normalized_email: 'referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-b:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '22222222-2222-4222-8222-222222222222', + dedupe_key: 'referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition.shouldEnqueueAffiliateSale).toBe(false); + expect(disposition.winningTouchType).toBe('referral'); + expect(disposition.disqualificationReason).toBeNull(); + + const decisions = await db + .select() + .from(kiloclaw_referral_reward_decisions) + .where( + eq(kiloclaw_referral_reward_decisions.conversion_id, disposition.conversionId ?? '') + ); + expect(decisions).toHaveLength(2); + expect(decisions.map(decision => decision.outcome).sort()).toEqual(['granted', 'granted']); + + const rewards = await db + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.conversion_id, disposition.conversionId ?? '')); + expect(rewards).toHaveLength(2); + expect(rewards.map(reward => reward.status).sort()).toEqual(['applied', 'applied']); + + const applications = await db.select().from(kiloclaw_referral_reward_applications); + expect(applications).toHaveLength(2); + expect( + applications.map(application => String(application.new_renewal_boundary)).sort() + ).toEqual(['2026-06-01 00:00:00+00', '2026-06-01 00:00:00+00']); + + const subscriptions = await db + .select({ + userId: kiloclaw_subscriptions.user_id, + currentPeriodEnd: kiloclaw_subscriptions.current_period_end, + creditRenewalAt: kiloclaw_subscriptions.credit_renewal_at, + }) + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.plan, 'standard')); + expect(subscriptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + userId: referrer.id, + currentPeriodEnd: '2026-06-01 00:00:00+00', + creditRenewalAt: '2026-06-01 00:00:00+00', + }), + expect.objectContaining({ + userId: referee.id, + currentPeriodEnd: '2026-06-01 00:00:00+00', + creditRenewalAt: '2026-06-01 00:00:00+00', + }), + ]) + ); + + const reports = await db.select().from(impact_conversion_reports); + expect(reports).toHaveLength(1); + expect(reports[0].state).toBe('delivered'); + expect(mockSendImpactConversionPayload).toHaveBeenCalledTimes(1); + }); + + it('resolves referrers through referral_codes when no participant mapping exists', async () => { + const referrer = await insertTestUser({ + google_user_email: 'referral-code-referrer@example.com', + normalized_email: 'referral-code-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'referral-code-referee@example.com', + normalized_email: 'referral-code-referee@example.com', + }); + const impactReferralId = 'REFERRER5616'; + const sourcePaymentId = 'kiloclaw-subscription:instance-referral-code:2026-04'; + + await db.insert(referral_codes).values({ + kilo_user_id: referrer.id, + code: impactReferralId, + }); + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: 'abababab-abab-4bab-8bab-abababababab', + dedupe_key: 'referral-code-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: impactReferralId, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toMatchObject({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + disqualificationReason: null, + }); + + const [conversion] = await db.select().from(kiloclaw_referral_conversions); + expect(conversion.referrer_user_id).toBe(referrer.id); + expect(conversion.qualified).toBe(true); + }); + + it('allows signup referral touches captured shortly after user creation', async () => { + const referrer = await insertTestUser({ + google_user_email: 'signup-race-referrer@example.com', + normalized_email: 'signup-race-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'signup-race-referee@example.com', + normalized_email: 'signup-race-referee@example.com', + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-signup-race:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: 'cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd', + dedupe_key: 'signup-race-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + landing_path: '/users/after-sign-in?signup=true&callbackPath=%2Fclaw%2Fnew', + touched_at: '2026-04-01T00:00:02.000Z', + expires_at: '2026-05-01T00:00:02.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toMatchObject({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + disqualificationReason: null, + }); + }); + + it('logs terminal 4xx Impact conversion report failures and stops retrying unchanged payloads', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockSendImpactConversionPayload.mockResolvedValueOnce({ + ok: false, + failureKind: 'http_4xx', + statusCode: 400, + responseBody: 'bad request', + }); + + const referrer = await insertTestUser({ + google_user_email: 'terminal-report-referrer@example.com', + normalized_email: 'terminal-report-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'terminal-report-referee@example.com', + normalized_email: 'terminal-report-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-terminal-report:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '12121212-1212-4212-8212-121212121212', + dedupe_key: 'terminal-report-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: expect.any(String), + disqualificationReason: null, + }); + + const [report] = await db.select().from(impact_conversion_reports); + expect(report.state).toBe('failed'); + expect(report.next_retry_at).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[kiloclaw-referrals] Impact conversion report failed permanently', + expect.objectContaining({ + reportId: report.id, + conversionId: disposition.conversionId, + statusCode: 400, + failureKind: 'http_4xx', + }) + ); + }); + + it('fails closed when reward-bearing referral configuration is missing', async () => { + mockIsImpactConfigured.mockReturnValue(false); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const referrer = await insertTestUser({ + google_user_email: 'config-referrer@example.com', + normalized_email: 'config-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'config-referee@example.com', + normalized_email: 'config-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-config:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '77777777-7777-4777-8777-777777777777', + dedupe_key: 'missing-config-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: expect.any(String), + disqualificationReason: 'referral_missing_configuration', + }); + + const decisions = await db + .select({ + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + outcome: kiloclaw_referral_reward_decisions.outcome, + reason: kiloclaw_referral_reward_decisions.reason, + }) + .from(kiloclaw_referral_reward_decisions) + .where( + eq(kiloclaw_referral_reward_decisions.conversion_id, disposition.conversionId ?? '') + ); + expect(decisions).toHaveLength(2); + expect(decisions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiaryRole: 'referee', + outcome: 'disqualified', + reason: 'referral_missing_configuration', + }), + expect.objectContaining({ + beneficiaryRole: 'referrer', + outcome: 'disqualified', + reason: 'referral_missing_configuration', + }), + ]) + ); + + const rewards = await db.select().from(kiloclaw_referral_rewards); + expect(rewards).toHaveLength(0); + + const reports = await db.select().from(impact_conversion_reports); + expect(reports).toHaveLength(1); + expect(reports[0].state).toBe('failed'); + expect(reports[0].response_payload).toMatchObject({ + error: 'missing_reward_bearing_referral_configuration', + }); + expect(mockSendImpactConversionPayload).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[kiloclaw-referrals] reward-bearing referral configuration is incomplete', + expect.objectContaining({ + sourcePaymentId, + userId: referee.id, + impactPerformanceConfigured: false, + impactAdvocateConfigured: true, + }) + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('disqualifies referral touches captured after the user already existed', async () => { + const referrer = await insertTestUser({ + google_user_email: 'old-referrer@example.com', + normalized_email: 'old-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'existing-referee@example.com', + normalized_email: 'existing-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-c:2026-04'; + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '33333333-3333-4333-8333-333333333333', + dedupe_key: 'late-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2030-01-01T00:00:00.000Z', + expires_at: '2030-02-01T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2030-01-05T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: expect.any(String), + disqualificationReason: 'referral_existing_user_before_touch', + }); + + const rewards = await db.select().from(kiloclaw_referral_rewards); + expect(rewards).toHaveLength(0); + expect(mockSendImpactConversionPayload).not.toHaveBeenCalled(); + }); + + it('does not preserve affiliate renewals when no affiliate touch has previously won a sale', async () => { + const referee = await insertTestUser({ + google_user_email: 'renewal-no-sale-touch@example.com', + normalized_email: 'renewal-no-sale-touch@example.com', + }); + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values([ + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: 'kiloclaw-subscription:instance-renewal:2026-03', + }, + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard renewal', + credit_category: 'kiloclaw-subscription:instance-renewal:2026-04', + }, + ]); + await db.insert(user_affiliate_attributions).values({ + user_id: referee.id, + provider: 'impact', + tracking_id: 'impact-click-123', + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '88888888-8888-4888-8888-888888888888', + dedupe_key: 'affiliate-touch-without-sale', + user_id: referee.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'impact-click-123', + tracking_value_length: 16, + is_tracking_value_accepted: true, + im_ref: 'impact-click-123', + touched_at: '2026-03-01T00:00:00.000Z', + expires_at: '2026-03-31T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-renewal:2026-04', + orderId: 'kiloclaw-subscription:instance-renewal:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'not_first_paid_period', + }); + }); + + it('preserves affiliate renewals when a prior affiliate touch already won the sale', async () => { + const referee = await insertTestUser({ + google_user_email: 'renewal-sale-touch@example.com', + normalized_email: 'renewal-sale-touch@example.com', + }); + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values([ + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: 'kiloclaw-subscription:instance-renewal-sale:2026-03', + }, + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard renewal', + credit_category: 'kiloclaw-subscription:instance-renewal-sale:2026-04', + }, + ]); + await db.insert(user_affiliate_attributions).values({ + user_id: referee.id, + provider: 'impact', + tracking_id: 'impact-click-456', + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '99999999-9999-4999-8999-999999999999', + dedupe_key: 'affiliate-touch-with-sale', + user_id: referee.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'impact-click-456', + tracking_value_length: 16, + is_tracking_value_accepted: true, + im_ref: 'impact-click-456', + touched_at: '2026-03-01T00:00:00.000Z', + expires_at: '2026-03-31T00:00:00.000Z', + sale_attributed_at: '2026-03-05T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-renewal-sale:2026-04', + orderId: 'kiloclaw-subscription:instance-renewal-sale:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: null, + disqualificationReason: 'not_first_paid_period', + }); + }); + + it('disqualifies conversions when the user has no current personal KiloClaw subscription', async () => { + const referee = await insertTestUser({ + google_user_email: 'no-personal-sub@example.com', + normalized_email: 'no-personal-sub@example.com', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-missing:2026-04', + orderId: 'kiloclaw-subscription:instance-missing:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'referral_non_personal_subscription', + }); + }); + + it('disqualifies admin-adjusted subscriptions unless explicitly overridden', async () => { + const referee = await insertTestUser({ + google_user_email: 'admin-adjusted@example.com', + normalized_email: 'admin-adjusted@example.com', + }); + const { subscriptionId } = await insertActivePersonalSubscription(referee.id); + await db.insert(kiloclaw_subscription_change_log).values({ + subscription_id: subscriptionId, + actor_type: 'system', + actor_id: 'admin-test', + action: 'admin_override', + reason: 'manual adjustment', + before_state: null, + after_state: null, + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-admin-adjusted:2026-04', + orderId: 'kiloclaw-subscription:instance-admin-adjusted:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'referral_admin_adjusted_subscription', + }); + }); + + it('disqualifies explicitly flagged test conversions unless an override marks them eligible', async () => { + const referee = await insertTestUser({ + google_user_email: 'test-flagged@example.com', + normalized_email: 'test-flagged@example.com', + }); + await insertActivePersonalSubscription(referee.id); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-test-flagged:2026-04', + orderId: 'kiloclaw-subscription:instance-test-flagged:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + qualificationContext: { + sourceType: 'test', + }, + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'referral_test_subscription', + }); + }); + + it('allows explicitly overridden manual conversions to continue through normal qualification', async () => { + const referee = await insertTestUser({ + google_user_email: 'override-eligible@example.com', + normalized_email: 'override-eligible@example.com', + }); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: 'kiloclaw-subscription:instance-override-eligible:2026-04', + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + dedupe_key: 'override-eligible-affiliate-touch', + user_id: referee.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'impact-click-override', + tracking_value_length: 21, + is_tracking_value_accepted: true, + im_ref: 'impact-click-override', + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-override-eligible:2026-04', + orderId: 'kiloclaw-subscription:instance-override-eligible:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + qualificationContext: { + sourceType: 'manual_adjustment', + overrideEligible: true, + }, + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: expect.any(String), + disqualificationReason: 'referral_affiliate_won', + }); + }); + + it('applies pending referrer rewards after the referrer later starts an eligible subscription', async () => { + const referrer = await insertTestUser({ + google_user_email: 'pending-referrer@example.com', + normalized_email: 'pending-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'pending-referee@example.com', + normalized_email: 'pending-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-d:2026-04'; + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '44444444-4444-4444-8444-444444444444', + dedupe_key: 'pending-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + const rewardsBefore = await db + .select({ + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + status: kiloclaw_referral_rewards.status, + }) + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.conversion_id, disposition.conversionId ?? '')); + expect(rewardsBefore).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiaryUserId: referee.id, + status: 'applied', + }), + expect.objectContaining({ + beneficiaryUserId: referrer.id, + status: 'pending', + }), + ]) + ); + + await insertActivePersonalSubscription(referrer.id); + + const summary = await processQueuedKiloClawReferralRewards({ + beneficiaryUserIds: [referrer.id], + }); + expect(summary).toEqual({ + claimed: 1, + applied: 1, + expired: 0, + pending: 0, + failed: 0, + }); + + const [referrerReward] = await db + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, referrer.id)); + expect(referrerReward.status).toBe('applied'); + + const queuedRedemptions = await db.select().from(impact_advocate_reward_redemptions); + expect(queuedRedemptions).toHaveLength(2); + expect(queuedRedemptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ beneficiary_user_id: referee.id, state: 'queued' }), + expect.objectContaining({ beneficiary_user_id: referrer.id, state: 'queued' }), + ]) + ); + + const redemptionSummary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + expect(redemptionSummary).toEqual({ claimed: 2, redeemed: 2, retried: 0, failed: 0 }); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledTimes(2); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledWith({ + accountId: 'pending-referee@example.com', + userId: 'pending-referee@example.com', + rewardTypeFilter: 'CREDIT', + }); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledWith({ + accountId: 'pending-referrer@example.com', + userId: 'pending-referrer@example.com', + rewardTypeFilter: 'CREDIT', + }); + expect(mockSendImpactAdvocateRewardRedemptionPayload).toHaveBeenCalledTimes(2); + expect(mockSendImpactAdvocateRewardRedemptionPayload).toHaveBeenCalledWith({ + rewardId: 'impact-reward-123', + amount: 1, + unit: 'free-months', + }); + + const redeemedRedemptions = await db.select().from(impact_advocate_reward_redemptions); + expect(redeemedRedemptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiary_user_id: referee.id, + state: 'redeemed', + impact_reward_id: 'impact-reward-123', + }), + expect.objectContaining({ + beneficiary_user_id: referrer.id, + state: 'redeemed', + impact_reward_id: 'impact-reward-123', + }), + ]) + ); + }); + + it('leaves local reward state unchanged when Stripe reward application fails', async () => { + const referrer = await insertTestUser({ + google_user_email: 'stripe-failure-referrer@example.com', + normalized_email: 'stripe-failure-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'stripe-failure-referee@example.com', + normalized_email: 'stripe-failure-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-stripe-failure:2026-04'; + + mockStripeSubscriptionUpdate.mockRejectedValueOnce(new Error('stripe exploded')); + + await insertActivePersonalSubscription(referee.id, { + stripe_subscription_id: 'sub_referee_failure_123', + }); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '54545454-5454-4545-8545-545454545454', + dedupe_key: 'stripe-failure-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + const [subscription] = await db + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.user_id, referee.id)); + expect(subscription.current_period_end).toBe('2026-05-01 00:00:00+00'); + + const refereeRewards = await db + .select({ + status: kiloclaw_referral_rewards.status, + appliedAt: kiloclaw_referral_rewards.applied_at, + }) + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, referee.id)); + expect(refereeRewards).toEqual([ + { + status: 'earned', + appliedAt: null, + }, + ]); + + const applications = await db.select().from(kiloclaw_referral_reward_applications); + expect(applications).toHaveLength(0); + }); + + it('keeps stripe-funded reward application in sync with Stripe trial-end billing delays', async () => { + const referrer = await insertTestUser({ + google_user_email: 'stripe-referrer@example.com', + normalized_email: 'stripe-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'stripe-referee@example.com', + normalized_email: 'stripe-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-e:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id, { + stripe_subscription_id: 'sub_referee_123', + }); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '55555555-5555-4555-8555-555555555555', + dedupe_key: 'stripe-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(mockStripeSubscriptionUpdate).toHaveBeenCalledWith( + 'sub_referee_123', + expect.objectContaining({ + proration_behavior: 'none', + trial_end: Math.floor(new Date('2026-06-01T00:00:00.000Z').getTime() / 1000), + }), + expect.objectContaining({ + idempotencyKey: expect.stringContaining('stripe-apply'), + }) + ); + }); + + it('cancels unapplied rewards and marks applied rewards for review when the qualifying payment is charged back', async () => { + const referrer = await insertTestUser({ + google_user_email: 'reversal-referrer@example.com', + normalized_email: 'reversal-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'reversal-referee@example.com', + normalized_email: 'reversal-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-f:2026-04'; + + mockSendImpactConversionPayload.mockResolvedValueOnce({ + ok: true, + delivery: 'immediate', + actionId: '1000.2000.3000', + responseBody: '{}', + }); + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '66666666-6666-4666-8666-666666666666', + dedupe_key: 'reversal-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + const summary = await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId, + reason: 'chargeback', + occurredAt: new Date('2026-04-15T00:00:00.000Z'), + }); + expect(summary).toEqual({ + conversionId: expect.any(String), + canceledRewards: 1, + reviewRequiredRewards: 1, + impactActionReversed: true, + }); + + const rewards = await db + .select({ + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + status: kiloclaw_referral_rewards.status, + reviewReason: kiloclaw_referral_rewards.review_reason, + }) + .from(kiloclaw_referral_rewards); + expect(rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiaryUserId: referee.id, + status: 'review_required', + reviewReason: 'referral_payment_chargeback', + }), + expect.objectContaining({ + beneficiaryUserId: referrer.id, + status: 'canceled', + reviewReason: 'referral_payment_chargeback', + }), + ]) + ); + expect(mockReverseImpactAction).toHaveBeenCalledWith({ actionId: '1000.2000.3000' }); + }); + }); +}); diff --git a/apps/web/src/lib/kiloclaw-referrals.ts b/apps/web/src/lib/kiloclaw-referrals.ts new file mode 100644 index 0000000000..5442eac0eb --- /dev/null +++ b/apps/web/src/lib/kiloclaw-referrals.ts @@ -0,0 +1,2188 @@ +import 'server-only'; + +import { addMonths } from 'date-fns'; +import { and, asc, count, eq, inArray, like, lt, lte, or, sql } from 'drizzle-orm'; + +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { + IMPACT_ACTION_TRACKER_IDS, + buildSalePayload, + hashEmailForImpact, + isImpactConfigured, + reverseImpactAction, + sendImpactConversionPayload, + type ImpactConversionPayload, + type ImpactDispatchResult, +} from '@/lib/impact'; +import { + isImpactAdvocateConfigured, + sendImpactAdvocateRewardLookupPayload, + sendImpactAdvocateRewardRedemptionPayload, + type ImpactAdvocateDispatchResult, +} from '@/lib/impact-advocate'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; +import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact-referral'; +import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; +import { client as stripe } from '@/lib/stripe-client'; +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; +import { + credit_transactions, + deleted_user_email_tombstones, + impact_advocate_participants, + impact_advocate_reward_redemptions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, + kilocode_users, + referral_codes, + type KiloClawAttributionTouch, + type KiloClawSubscription, +} from '@kilocode/db/schema'; +import { + ImpactAdvocateRewardRedemptionState, + ImpactConversionReportState, + KiloClawAttributionTouchType, + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +type DatabaseClient = typeof db | DrizzleTransaction; + +type WinningAttributionResolution = + | { + winner: 'referral'; + referralTouch: KiloClawAttributionTouch; + affiliateTouch: KiloClawAttributionTouch | null; + } + | { + winner: 'affiliate'; + affiliateTouch: KiloClawAttributionTouch; + referralTouch: KiloClawAttributionTouch | null; + } + | { + winner: 'none'; + affiliateTouch: KiloClawAttributionTouch | null; + referralTouch: KiloClawAttributionTouch | null; + }; + +export type KiloClawPaidConversionDisposition = { + shouldEnqueueAffiliateSale: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; +}; + +export type ImpactConversionReportDispatchSummary = { + claimed: number; + delivered: number; + retried: number; + failed: number; +}; + +export type ReferralRewardProcessingSummary = { + claimed: number; + applied: number; + expired: number; + pending: number; + failed: number; +}; + +export type ImpactAdvocateRewardRedemptionDispatchSummary = { + claimed: number; + redeemed: number; + retried: number; + failed: number; +}; + +export type AdverseReferralPaymentReason = 'chargeback' | 'refund' | 'fraud'; + +export type PaidConversionQualificationContext = { + sourceType?: 'normal' | 'test' | 'fraudulent' | 'admin_created' | 'manual_adjustment'; + overrideEligible?: boolean; +}; + +export type AdverseReferralPaymentSummary = { + conversionId: string | null; + canceledRewards: number; + reviewRequiredRewards: number; + impactActionReversed: boolean; +}; + +const REFERRAL_REWARD_ACTOR = { + actorType: 'system', + actorId: 'kiloclaw-referrals', +} as const; + +const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000; +const IMPACT_ADVOCATE_REWARD_UNIT = 'free-months'; + +function getDatabaseClient(database?: DatabaseClient): DatabaseClient { + return database ?? db; +} + +function reportBackoffDelayMs(attemptCount: number): number { + const maxDelayMs = 60 * 60 * 1000; + const initialDelayMs = 60 * 1000; + return Math.min(initialDelayMs * 2 ** Math.max(attemptCount, 0), maxDelayMs); +} + +function nextReportRetryAt(attemptCount: number): string { + return new Date(Date.now() + reportBackoffDelayMs(attemptCount)).toISOString(); +} + +function nextReportClaimExpiresAt(): string { + return new Date(Date.now() + 15 * 60 * 1000).toISOString(); +} + +function referralDisqualificationReason(reason: string): string { + return `referral_${reason}`; +} + +function hasAcceptedTrackingValue(touch: KiloClawAttributionTouch): boolean { + return touch.is_tracking_value_accepted && Boolean(touch.opaque_tracking_value?.trim()); +} + +function isTouchValidAtConversion(touch: KiloClawAttributionTouch, convertedAt: Date): boolean { + return ( + hasAcceptedTrackingValue(touch) && + new Date(touch.touched_at).getTime() <= convertedAt.getTime() && + convertedAt.getTime() < new Date(touch.expires_at).getTime() + ); +} + +export function resolveWinningAttributionTouch(params: { + touches: KiloClawAttributionTouch[]; + convertedAt: Date; +}): WinningAttributionResolution { + const validReferralTouches = params.touches + .filter( + touch => + touch.touch_type === KiloClawAttributionTouchType.Referral && + isTouchValidAtConversion(touch, params.convertedAt) + ) + .sort((a, b) => new Date(a.touched_at).getTime() - new Date(b.touched_at).getTime()); + const validAffiliateTouches = params.touches + .filter( + touch => + touch.touch_type === KiloClawAttributionTouchType.Affiliate && + isTouchValidAtConversion(touch, params.convertedAt) + ) + .sort((a, b) => new Date(a.touched_at).getTime() - new Date(b.touched_at).getTime()); + + const oldestReferralTouch = validReferralTouches[0] ?? null; + const oldestAffiliateTouch = validAffiliateTouches[0] ?? null; + + if (!oldestReferralTouch && !oldestAffiliateTouch) { + return { + winner: 'none', + affiliateTouch: null, + referralTouch: null, + }; + } + + if (!oldestReferralTouch && oldestAffiliateTouch) { + return { + winner: 'affiliate', + affiliateTouch: oldestAffiliateTouch, + referralTouch: null, + }; + } + + if (!oldestAffiliateTouch && oldestReferralTouch) { + return { + winner: 'referral', + affiliateTouch: null, + referralTouch: oldestReferralTouch, + }; + } + + const preservedAffiliateTouch = validAffiliateTouches.find(touch => { + if (!touch.sale_attributed_at) return false; + return ( + new Date(touch.sale_attributed_at).getTime() < + new Date(oldestReferralTouch.touched_at).getTime() + ); + }); + + if (preservedAffiliateTouch) { + return { + winner: 'affiliate', + affiliateTouch: preservedAffiliateTouch, + referralTouch: oldestReferralTouch, + }; + } + + return { + winner: 'referral', + affiliateTouch: oldestAffiliateTouch, + referralTouch: oldestReferralTouch, + }; +} + +async function countMonetizedKiloClawPaymentPeriods( + userId: string, + database: DatabaseClient +): Promise { + const [result] = await database + .select({ count: count() }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.kilo_user_id, userId), + eq(credit_transactions.is_free, false), + lt(credit_transactions.amount_microdollars, 0), + or( + like(credit_transactions.credit_category, 'kiloclaw-subscription:%'), + like(credit_transactions.credit_category, 'kiloclaw-subscription-commit:%'), + like(credit_transactions.credit_category, 'kiloclaw-settlement:%') + ) + ) + ); + + return result?.count ?? 0; +} + +async function findAcceptedUserTouches(params: { + userId: string; + convertedAt: Date; + database: DatabaseClient; +}): Promise { + return await params.database + .select() + .from(kiloclaw_attribution_touches) + .where( + and( + eq(kiloclaw_attribution_touches.user_id, params.userId), + lte(kiloclaw_attribution_touches.touched_at, params.convertedAt.toISOString()) + ) + ) + .orderBy( + asc(kiloclaw_attribution_touches.touched_at), + asc(kiloclaw_attribution_touches.created_at) + ); +} + +function buildOpaqueReferralIdentifierFromTouch(touch: KiloClawAttributionTouch): string | null { + const referralIdentifier = buildImpactReferralId(touch)?.trim(); + return referralIdentifier ? referralIdentifier : null; +} + +async function resolveReferrerUserIdFromReferralTouch(params: { + referralTouch: KiloClawAttributionTouch; + database: DatabaseClient; +}): Promise { + const opaqueReferralIdentifier = buildOpaqueReferralIdentifierFromTouch(params.referralTouch); + if (!opaqueReferralIdentifier) { + return null; + } + + const [participant] = await params.database + .select({ userId: impact_advocate_participants.user_id }) + .from(impact_advocate_participants) + .where(eq(impact_advocate_participants.opaque_referral_identifier, opaqueReferralIdentifier)) + .limit(1); + + if (participant) { + return participant.userId; + } + + const [referralCode] = await params.database + .select({ userId: referral_codes.kilo_user_id }) + .from(referral_codes) + .where(eq(referral_codes.code, opaqueReferralIdentifier)) + .limit(1); + + return referralCode?.userId ?? null; +} + +function wasReferralTouchCapturedDuringSignup(params: { + userCreatedAt: string; + referralTouch: KiloClawAttributionTouch; +}): boolean { + if (!params.referralTouch.landing_path) { + return false; + } + + const touchTime = new Date(params.referralTouch.touched_at).getTime(); + const userCreatedTime = new Date(params.userCreatedAt).getTime(); + if (touchTime < userCreatedTime) { + return false; + } + + if (touchTime - userCreatedTime > SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS) { + return false; + } + + try { + const landingUrl = new URL(params.referralTouch.landing_path, 'http://localhost'); + return landingUrl.searchParams.get('signup') === 'true'; + } catch { + return false; + } +} + +async function hasDeletedUserEmailTombstone(params: { + normalizedEmail: string | null; + database: DatabaseClient; +}): Promise { + if (!params.normalizedEmail) { + return false; + } + + const [row] = await params.database + .select({ hash: deleted_user_email_tombstones.normalized_email_hash }) + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone(params.normalizedEmail) + ) + ) + .limit(1); + + return Boolean(row); +} + +async function hasActiveEligiblePersonalSubscription( + userId: string, + database: DatabaseClient +): Promise { + const row = await resolveCurrentPersonalSubscriptionRow({ userId, dbOrTx: database }); + if (!row) return false; + + return ( + row.subscription.plan !== 'trial' && + row.subscription.status === 'active' && + !row.subscription.cancel_at_period_end && + row.subscription.suspended_at === null && + row.subscription.past_due_since === null + ); +} + +async function markAffiliateTouchSaleAttributed(params: { + database: DatabaseClient; + affiliateTouchId: string; + convertedAt: Date; +}): Promise { + await params.database + .update(kiloclaw_attribution_touches) + .set({ + sale_attributed_at: sql`COALESCE(${kiloclaw_attribution_touches.sale_attributed_at}, ${params.convertedAt.toISOString()}::timestamptz)`, + }) + .where(eq(kiloclaw_attribution_touches.id, params.affiliateTouchId)); +} + +async function lockReferrerRewardCapacity( + referrerUserId: string, + database: DatabaseClient +): Promise { + await database.execute( + sql`SELECT ${kilocode_users.id} FROM ${kilocode_users} WHERE ${kilocode_users.id} = ${referrerUserId} FOR UPDATE` + ); +} + +async function getGrantedReferrerMonths( + referrerUserId: string, + database: DatabaseClient +): Promise { + const [result] = await database + .select({ + totalMonths: sql`COALESCE(SUM(${kiloclaw_referral_reward_decisions.months_granted}), 0)`, + }) + .from(kiloclaw_referral_reward_decisions) + .where( + and( + eq(kiloclaw_referral_reward_decisions.beneficiary_user_id, referrerUserId), + eq( + kiloclaw_referral_reward_decisions.beneficiary_role, + KiloClawReferralBeneficiaryRole.Referrer + ), + eq(kiloclaw_referral_reward_decisions.outcome, KiloClawReferralDecisionOutcome.Granted) + ) + ); + + return Number(result?.totalMonths ?? 0); +} + +async function hasSaleAttributedAffiliateTouch(params: { + userId: string; + database: DatabaseClient; +}): Promise { + const [touch] = await params.database + .select({ id: kiloclaw_attribution_touches.id }) + .from(kiloclaw_attribution_touches) + .where( + and( + eq(kiloclaw_attribution_touches.user_id, params.userId), + eq(kiloclaw_attribution_touches.touch_type, KiloClawAttributionTouchType.Affiliate), + sql`${kiloclaw_attribution_touches.sale_attributed_at} IS NOT NULL` + ) + ) + .limit(1); + + return Boolean(touch); +} + +async function hasAdminOverrideHistory(params: { + subscriptionId: string; + database: DatabaseClient; +}): Promise { + const [row] = await params.database + .select({ id: kiloclaw_subscription_change_log.id }) + .from(kiloclaw_subscription_change_log) + .where( + and( + eq(kiloclaw_subscription_change_log.subscription_id, params.subscriptionId), + eq(kiloclaw_subscription_change_log.action, 'admin_override') + ) + ) + .limit(1); + + return Boolean(row); +} + +async function getHeuristicSourcePaymentDisqualificationReason(params: { + sourcePaymentId: string; + database: DatabaseClient; +}): Promise { + const [transaction] = await params.database + .select({ + description: credit_transactions.description, + isFree: credit_transactions.is_free, + }) + .from(credit_transactions) + .where(eq(credit_transactions.credit_category, params.sourcePaymentId)) + .limit(1); + + if (!transaction) { + return null; + } + + if (transaction.isFree) { + return referralDisqualificationReason('fully_comped_period'); + } + + const description = transaction.description?.trim().toLowerCase() ?? ''; + if (description.includes('fraud')) { + return referralDisqualificationReason('fraudulent_subscription'); + } + if (description.includes('manual')) { + return referralDisqualificationReason('manual_adjustment_subscription'); + } + if (description.includes('admin')) { + return referralDisqualificationReason('admin_created_subscription'); + } + if (description.includes('test')) { + return referralDisqualificationReason('test_subscription'); + } + + return null; +} + +function getObjectProperty(record: unknown, key: string): unknown { + if (typeof record !== 'object' || record === null) { + return undefined; + } + + if (!Object.prototype.hasOwnProperty.call(record, key)) { + return undefined; + } + + return Reflect.get(record, key); +} + +function getCaseInsensitiveObjectProperty(record: unknown, key: string): unknown { + if (typeof record !== 'object' || record === null) { + return undefined; + } + + const keys = Object.keys(record); + const matchedKey = keys.find(candidate => candidate.toLowerCase() === key.toLowerCase()); + return matchedKey ? Reflect.get(record, matchedKey) : undefined; +} + +function getStringProperty(record: unknown, keys: string[]): string | null { + for (const key of keys) { + const value = getCaseInsensitiveObjectProperty(record, key); + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} + +function getNumberProperty(record: unknown, keys: string[]): number | null { + for (const key of keys) { + const value = getCaseInsensitiveObjectProperty(record, key); + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} + +function rewardHasUnit(reward: unknown, unit: string): boolean { + const unitValue = + getStringProperty(reward, ['unit', 'Unit', 'currency']) ?? + getStringProperty(getCaseInsensitiveObjectProperty(reward, 'credit'), ['unit', 'Unit']) ?? + getStringProperty(getCaseInsensitiveObjectProperty(reward, 'value'), ['unit', 'Unit']); + return !unitValue || unitValue.toLowerCase() === unit.toLowerCase(); +} + +function rewardHasAmount(reward: unknown, amount: number): boolean { + const amountValue = + getNumberProperty(reward, ['amount', 'Amount', 'remainingAmount', 'RemainingAmount']) ?? + getNumberProperty(getCaseInsensitiveObjectProperty(reward, 'credit'), ['amount', 'Amount']) ?? + getNumberProperty(getCaseInsensitiveObjectProperty(reward, 'value'), ['amount', 'Amount']); + return amountValue === null || amountValue >= amount; +} + +function rewardIsCredit(reward: unknown): boolean { + const type = getStringProperty(reward, ['type', 'Type', 'rewardType', 'RewardType']); + return !type || type.toUpperCase() === 'CREDIT'; +} + +function rewardIsRedeemable(reward: unknown): boolean { + const status = getStringProperty(reward, ['status', 'Status', 'state', 'State']); + if (status) { + const normalizedStatus = status.toUpperCase().replaceAll(' ', '_'); + if ( + normalizedStatus === 'REDEEMED' || + normalizedStatus === 'CANCELLED' || + normalizedStatus === 'CANCELED' + ) { + return false; + } + } + + const redeemed = getCaseInsensitiveObjectProperty(reward, 'redeemed'); + if (redeemed === true) return false; + + const terminalTimestamps = [ + 'redeemedAt', + 'dateRedeemed', + 'cancelledAt', + 'canceledAt', + 'dateCancelled', + 'dateCanceled', + ]; + return !terminalTimestamps.some(key => Boolean(getCaseInsensitiveObjectProperty(reward, key))); +} + +function getImpactAdvocateRewardId(reward: unknown): string | null { + return getStringProperty(reward, ['id', 'Id', 'ID', 'rewardId', 'RewardId']); +} + +function selectImpactAdvocateRewardId(params: { + rewards: unknown[]; + amount: number; + unit: string; +}): string | null { + for (const reward of params.rewards) { + const rewardId = getImpactAdvocateRewardId(reward); + if ( + rewardId && + rewardIsCredit(reward) && + rewardHasUnit(reward, params.unit) && + rewardHasAmount(reward, params.amount) && + rewardIsRedeemable(reward) + ) { + return rewardId; + } + } + + return null; +} + +function isAlreadyRedeemedResponse(responseBody: string | null | undefined): boolean { + const normalized = responseBody?.toLowerCase() ?? ''; + return normalized.includes('already') && normalized.includes('redeem'); +} + +function getImpactActionIdFromResponsePayload(payload: unknown): string | null { + const value = getObjectProperty(payload, 'actionId'); + return typeof value === 'string' && value.trim() ? value : null; +} + +function getRewardApplicationReason(reason: string): string { + return `referral_reward_${reason}`; +} + +function getAdversePaymentReason(reason: AdverseReferralPaymentReason): string { + return `referral_payment_${reason}`; +} + +function getQualificationDisqualificationReason( + sourceType: Exclude +): string { + switch (sourceType) { + case 'test': + return referralDisqualificationReason('test_subscription'); + case 'fraudulent': + return referralDisqualificationReason('fraudulent_subscription'); + case 'admin_created': + return referralDisqualificationReason('admin_created_subscription'); + case 'manual_adjustment': + return referralDisqualificationReason('manual_adjustment_subscription'); + } +} + +function getRewardBearingReferralConfigurationState() { + const impactPerformanceConfigured = isImpactConfigured(); + const impactAdvocateConfigured = isImpactAdvocateConfigured(); + + return { + impactPerformanceConfigured, + impactAdvocateConfigured, + isConfigured: impactPerformanceConfigured && impactAdvocateConfigured, + }; +} + +function logRewardBearingReferralConfigurationFailure(params: { + sourcePaymentId?: string; + conversionId?: string; + rewardId?: string; + userId?: string; +}): void { + const configurationState = getRewardBearingReferralConfigurationState(); + console.error('[kiloclaw-referrals] reward-bearing referral configuration is incomplete', { + ...params, + impactPerformanceConfigured: configurationState.impactPerformanceConfigured, + impactAdvocateConfigured: configurationState.impactAdvocateConfigured, + }); +} + +function getNextRenewalBoundary(subscription: KiloClawSubscription): string | null { + return subscription.credit_renewal_at ?? subscription.current_period_end; +} + +function hasActiveEligibleSubscriptionRow(subscription: KiloClawSubscription): boolean { + return ( + subscription.plan !== 'trial' && + subscription.status === 'active' && + !subscription.cancel_at_period_end && + subscription.suspended_at === null && + subscription.past_due_since === null + ); +} + +function requiresDeferredStripeRewardApplication(subscription: KiloClawSubscription): boolean { + return Boolean(subscription.stripe_schedule_id || subscription.scheduled_plan); +} + +async function applyReferralRewardById( + rewardId: string, + options?: { stripeAlreadyApplied?: boolean } +): Promise<'applied' | 'expired' | 'pending' | 'noop'> { + const result = await db.transaction(async tx => { + const [reward] = await tx + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.id, rewardId)) + .limit(1); + + if (!reward) { + return 'noop'; + } + + if ( + reward.status === KiloClawReferralRewardStatus.Applied || + reward.status === KiloClawReferralRewardStatus.Canceled || + reward.status === KiloClawReferralRewardStatus.Expired || + reward.status === KiloClawReferralRewardStatus.Reversed || + reward.status === KiloClawReferralRewardStatus.ReviewRequired + ) { + return 'noop'; + } + + const now = new Date(); + if ( + reward.status === KiloClawReferralRewardStatus.Pending && + reward.expires_at && + now.getTime() >= new Date(reward.expires_at).getTime() + ) { + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Expired, + review_reason: getRewardApplicationReason('inactive_referrer_expired'), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + return 'expired'; + } + + if (!getRewardBearingReferralConfigurationState().isConfigured) { + logRewardBearingReferralConfigurationFailure({ + rewardId: reward.id, + userId: reward.beneficiary_user_id, + }); + return 'pending'; + } + + await lockReferrerRewardCapacity(reward.beneficiary_user_id, tx); + const currentSubscription = await resolveCurrentPersonalSubscriptionRow({ + userId: reward.beneficiary_user_id, + dbOrTx: tx, + }); + const subscription = currentSubscription?.subscription ?? null; + + if (!subscription || !hasActiveEligibleSubscriptionRow(subscription)) { + if (reward.status === KiloClawReferralRewardStatus.Earned) { + // Mirror the conversion-time invariant: a Referrer reward that lands + // in Pending because the referrer is no longer on an eligible paid + // personal subscription MUST carry the 12-month expiry from earned_at + // (see .specs/kiloclaw-referrals.md rule 66). Without this back-fill, + // a reward earned during a brief eligible window and then orphaned + // when the referrer churns would have expires_at = NULL forever. + const shouldBackfillExpiresAt = + reward.beneficiary_role === KiloClawReferralBeneficiaryRole.Referrer && + reward.expires_at === null; + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Pending, + ...(shouldBackfillExpiresAt + ? { + expires_at: addMonths(new Date(reward.earned_at), 12).toISOString(), + } + : {}), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + } + return 'pending'; + } + + const previousBoundary = getNextRenewalBoundary(subscription); + if (!previousBoundary) { + console.warn( + '[kiloclaw-referrals] reward application left pending due to ambiguous renewal boundary', + { + rewardId: reward.id, + userId: reward.beneficiary_user_id, + subscriptionId: subscription.id, + } + ); + if (reward.status === KiloClawReferralRewardStatus.Pending) { + await tx + .update(kiloclaw_referral_rewards) + .set({ status: KiloClawReferralRewardStatus.Earned }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + } + return 'pending'; + } + + if ( + subscription.stripe_subscription_id !== null && + requiresDeferredStripeRewardApplication(subscription) + ) { + console.warn( + '[kiloclaw-referrals] reward application deferred due to scheduled Stripe changes', + { + rewardId: reward.id, + userId: reward.beneficiary_user_id, + subscriptionId: subscription.id, + stripeScheduleId: subscription.stripe_schedule_id, + scheduledPlan: subscription.scheduled_plan, + } + ); + if (reward.status === KiloClawReferralRewardStatus.Pending) { + await tx + .update(kiloclaw_referral_rewards) + .set({ status: KiloClawReferralRewardStatus.Earned }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + } + return 'pending'; + } + + const appliedAt = now.toISOString(); + const newBoundary = addMonths(new Date(previousBoundary), reward.months_granted).toISOString(); + const localOperationId = `kiloclaw-referral-reward:${reward.id}:apply`; + const stripeIdempotencyKey = `kiloclaw-referral-reward:${reward.id}:stripe-apply`; + + if (subscription.stripe_subscription_id && !options?.stripeAlreadyApplied) { + return { + outcome: 'stripe_pending' as const, + stripeUpdate: { + stripeSubscriptionId: subscription.stripe_subscription_id, + trialEnd: Math.floor(new Date(newBoundary).getTime() / 1000), + idempotencyKey: stripeIdempotencyKey, + }, + }; + } + + const [beforeSubscription] = await tx + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.id, subscription.id)) + .limit(1); + const [afterSubscription] = await tx + .update(kiloclaw_subscriptions) + .set({ + current_period_end: newBoundary, + credit_renewal_at: + subscription.payment_source === 'credits' ? newBoundary : subscription.credit_renewal_at, + commit_ends_at: + subscription.plan === 'commit' && subscription.commit_ends_at + ? addMonths(new Date(subscription.commit_ends_at), reward.months_granted).toISOString() + : subscription.commit_ends_at, + }) + .where(eq(kiloclaw_subscriptions.id, subscription.id)) + .returning(); + + if (!afterSubscription) { + return 'noop'; + } + + const [appliedReward] = await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: subscription.id, + applied_at: appliedAt, + review_reason: null, + }) + .where( + and( + eq(kiloclaw_referral_rewards.id, reward.id), + or( + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Earned), + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Pending) + ), + sql`${kiloclaw_referral_rewards.applied_at} IS NULL` + ) + ) + .returning({ id: kiloclaw_referral_rewards.id }); + + if (!appliedReward) { + return 'noop'; + } + + await insertKiloClawSubscriptionChangeLog(tx, { + subscriptionId: subscription.id, + actor: REFERRAL_REWARD_ACTOR, + action: 'period_advanced', + reason: getRewardApplicationReason('applied'), + before: beforeSubscription ?? null, + after: afterSubscription, + }); + + const [existingApplication] = await tx + .select({ id: kiloclaw_referral_reward_applications.id }) + .from(kiloclaw_referral_reward_applications) + .where(eq(kiloclaw_referral_reward_applications.reward_id, reward.id)) + .limit(1); + + if (!existingApplication) { + await tx.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: reward.beneficiary_user_id, + subscription_id: subscription.id, + previous_renewal_boundary: previousBoundary, + new_renewal_boundary: newBoundary, + local_operation_id: localOperationId, + stripe_operation_id: subscription.stripe_subscription_id, + stripe_idempotency_key: subscription.stripe_subscription_id ? stripeIdempotencyKey : null, + applied_at: appliedAt, + }); + } + + await queueImpactAdvocateRewardRedemption({ rewardId: reward.id, database: tx }); + + return 'applied'; + }); + + if (typeof result === 'string') { + return result; + } + + await stripe.subscriptions.update( + result.stripeUpdate.stripeSubscriptionId, + { + trial_end: result.stripeUpdate.trialEnd, + proration_behavior: 'none', + }, + { + idempotencyKey: result.stripeUpdate.idempotencyKey, + } + ); + + return applyReferralRewardById(rewardId, { stripeAlreadyApplied: true }); +} + +export async function processQueuedKiloClawReferralRewards(params?: { + limit?: number; + beneficiaryUserIds?: string[]; +}): Promise { + const limit = params?.limit ?? 100; + const pendingRows = await db + .select({ id: kiloclaw_referral_rewards.id }) + .from(kiloclaw_referral_rewards) + .where( + and( + or( + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Pending), + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Earned) + ), + params?.beneficiaryUserIds?.length + ? inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.beneficiaryUserIds) + : undefined + ) + ) + .orderBy(asc(kiloclaw_referral_rewards.earned_at), asc(kiloclaw_referral_rewards.created_at)) + .limit(limit); + + const summary: ReferralRewardProcessingSummary = { + claimed: pendingRows.length, + applied: 0, + expired: 0, + pending: 0, + failed: 0, + }; + + for (const row of pendingRows) { + try { + const outcome = await applyReferralRewardById(row.id); + if (outcome === 'applied') { + summary.applied++; + } else if (outcome === 'expired') { + summary.expired++; + } else if (outcome === 'pending') { + summary.pending++; + } + } catch { + summary.failed++; + } + } + + return summary; +} + +async function queueImpactAdvocateRewardRedemption(params: { + rewardId: string; + database: DatabaseClient; +}): Promise { + const [reward] = await params.database + .select({ + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + monthsGranted: kiloclaw_referral_rewards.months_granted, + status: kiloclaw_referral_rewards.status, + email: kilocode_users.google_user_email, + }) + .from(kiloclaw_referral_rewards) + .innerJoin(kilocode_users, eq(kilocode_users.id, kiloclaw_referral_rewards.beneficiary_user_id)) + .where(eq(kiloclaw_referral_rewards.id, params.rewardId)) + .limit(1); + + if (!reward || reward.status !== KiloClawReferralRewardStatus.Applied) { + return; + } + + const accountId = reward.email.trim(); + if (!accountId) { + console.error('[kiloclaw-referrals] missing beneficiary email for Impact reward redemption', { + rewardId: params.rewardId, + beneficiaryUserId: reward.beneficiaryUserId, + }); + return; + } + + await params.database + .insert(impact_advocate_reward_redemptions) + .values({ + reward_id: reward.id, + dedupe_key: `impact-advocate-reward-redemption:${reward.id}`, + beneficiary_user_id: reward.beneficiaryUserId, + state: ImpactAdvocateRewardRedemptionState.Queued, + request_payload: { + lookup: { + accountId, + userId: accountId, + rewardTypeFilter: 'CREDIT', + }, + redemption: { + amount: reward.monthsGranted, + unit: IMPACT_ADVOCATE_REWARD_UNIT, + }, + } satisfies Record, + }) + .onConflictDoNothing({ target: [impact_advocate_reward_redemptions.reward_id] }); +} + +type ImpactAdvocateRewardRedemptionRequestPayload = { + lookup: { + accountId: string; + userId: string; + rewardTypeFilter: 'CREDIT'; + }; + redemption: { + amount: number; + unit: string; + }; +}; + +function isImpactConversionPayload(payload: unknown): payload is ImpactConversionPayload { + return ( + typeof payload === 'object' && + payload !== null && + typeof getObjectProperty(payload, 'CampaignId') === 'string' && + typeof getObjectProperty(payload, 'ActionTrackerId') === 'number' && + typeof getObjectProperty(payload, 'EventDate') === 'string' && + typeof getObjectProperty(payload, 'OrderId') === 'string' + ); +} + +function isRewardRedemptionRequestPayload( + payload: unknown +): payload is ImpactAdvocateRewardRedemptionRequestPayload { + const lookup = getObjectProperty(payload, 'lookup'); + const redemption = getObjectProperty(payload, 'redemption'); + return ( + typeof lookup === 'object' && + lookup !== null && + typeof redemption === 'object' && + redemption !== null && + typeof getObjectProperty(lookup, 'accountId') === 'string' && + typeof getObjectProperty(lookup, 'userId') === 'string' && + getObjectProperty(lookup, 'rewardTypeFilter') === 'CREDIT' && + typeof getObjectProperty(redemption, 'amount') === 'number' && + typeof getObjectProperty(redemption, 'unit') === 'string' + ); +} + +function buildFailurePayload(result: ImpactAdvocateDispatchResult): Record { + return { + failureKind: result.ok ? null : result.failureKind, + responseBody: result.responseBody ?? null, + error: result.ok ? null : (result.error ?? null), + }; +} + +async function persistRewardRedemptionFailure(params: { + redemptionId: string; + attemptCount: number; + result: ImpactAdvocateDispatchResult; + stage: 'lookup' | 'redeem'; + terminal?: boolean; +}): Promise<'retried' | 'failed'> { + const terminal = + params.terminal ?? (!params.result.ok && params.result.failureKind === 'http_4xx'); + const responsePayload = buildFailurePayload(params.result); + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: terminal + ? ImpactAdvocateRewardRedemptionState.Failed + : ImpactAdvocateRewardRedemptionState.Retrying, + attempt_count: params.attemptCount, + next_retry_at: terminal ? null : nextReportRetryAt(params.attemptCount), + response_status_code: params.result.ok ? null : (params.result.statusCode ?? null), + ...(params.stage === 'lookup' + ? { lookup_response_payload: responsePayload } + : { redeem_response_payload: responsePayload }), + }) + .where(eq(impact_advocate_reward_redemptions.id, params.redemptionId)); + + if (terminal) { + console.error('[kiloclaw-referrals] Impact Advocate reward redemption failed permanently', { + redemptionId: params.redemptionId, + stage: params.stage, + statusCode: params.result.ok ? null : (params.result.statusCode ?? null), + failureKind: params.result.ok ? null : params.result.failureKind, + }); + return 'failed'; + } + + return 'retried'; +} + +async function dispatchImpactAdvocateRewardRedemptionById( + redemptionId: string +): Promise<'redeemed' | 'retried' | 'failed'> { + const redemption = await db.query.impact_advocate_reward_redemptions.findFirst({ + where: eq(impact_advocate_reward_redemptions.id, redemptionId), + }); + if (!redemption) return 'failed'; + if (redemption.state === ImpactAdvocateRewardRedemptionState.Redeemed) return 'redeemed'; + if (redemption.state === ImpactAdvocateRewardRedemptionState.Failed) return 'failed'; + + const attemptCount = redemption.attempt_count + 1; + if (!isRewardRedemptionRequestPayload(redemption.request_payload)) { + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Failed, + attempt_count: attemptCount, + redeem_response_payload: { error: 'missing_request_payload' } satisfies Record< + string, + unknown + >, + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + return 'failed'; + } + + const lookupResult = await sendImpactAdvocateRewardLookupPayload( + redemption.request_payload.lookup + ); + if (!lookupResult.ok) { + return await persistRewardRedemptionFailure({ + redemptionId: redemption.id, + attemptCount, + result: lookupResult, + stage: 'lookup', + }); + } + + const persistedImpactRewardId = redemption.impact_reward_id?.trim() || null; + const impactRewardId = + persistedImpactRewardId ?? + selectImpactAdvocateRewardId({ + rewards: lookupResult.rewards ?? [], + amount: redemption.request_payload.redemption.amount, + unit: redemption.request_payload.redemption.unit, + }); + if (!impactRewardId) { + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Retrying, + attempt_count: attemptCount, + next_retry_at: nextReportRetryAt(attemptCount), + response_status_code: lookupResult.statusCode ?? null, + lookup_response_payload: { + error: 'impact_reward_not_found', + responseBody: lookupResult.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + return 'retried'; + } + + if (!persistedImpactRewardId) { + await db + .update(impact_advocate_reward_redemptions) + .set({ + impact_reward_id: impactRewardId, + lookup_response_payload: { + selectedRewardId: impactRewardId, + responseBody: lookupResult.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + } + + const redeemResult = await sendImpactAdvocateRewardRedemptionPayload({ + rewardId: impactRewardId, + ...redemption.request_payload.redemption, + }); + const isIdempotentAlreadyRedeemed = + !redeemResult.ok && + persistedImpactRewardId === impactRewardId && + isAlreadyRedeemedResponse(redeemResult.responseBody); + if (!redeemResult.ok && !isIdempotentAlreadyRedeemed) { + return await persistRewardRedemptionFailure({ + redemptionId: redemption.id, + attemptCount, + result: redeemResult, + stage: 'redeem', + }); + } + + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Redeemed, + impact_reward_id: impactRewardId, + attempt_count: attemptCount, + next_retry_at: null, + redeemed_at: new Date().toISOString(), + response_status_code: redeemResult.statusCode ?? null, + lookup_response_payload: { + selectedRewardId: impactRewardId, + responseBody: lookupResult.responseBody ?? null, + } satisfies Record, + redeem_response_payload: redeemResult.ok + ? ({ responseBody: redeemResult.responseBody ?? null } satisfies Record) + : ({ + alreadyRedeemed: true, + responseBody: redeemResult.responseBody ?? null, + } satisfies Record), + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + + return 'redeemed'; +} + +export async function dispatchQueuedImpactAdvocateRewardRedemptions(params?: { + limit?: number; +}): Promise { + const limit = params?.limit ?? 100; + const nowIso = new Date().toISOString(); + const rows = await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Retrying, + next_retry_at: nextReportClaimExpiresAt(), + }) + .where( + and( + or( + eq(impact_advocate_reward_redemptions.state, ImpactAdvocateRewardRedemptionState.Queued), + eq(impact_advocate_reward_redemptions.state, ImpactAdvocateRewardRedemptionState.Retrying) + ), + or( + sql`${impact_advocate_reward_redemptions.next_retry_at} IS NULL`, + lte(impact_advocate_reward_redemptions.next_retry_at, nowIso) + ), + sql`${impact_advocate_reward_redemptions.id} IN ( + SELECT ${impact_advocate_reward_redemptions.id} + FROM ${impact_advocate_reward_redemptions} + WHERE ${or( + eq( + impact_advocate_reward_redemptions.state, + ImpactAdvocateRewardRedemptionState.Queued + ), + eq( + impact_advocate_reward_redemptions.state, + ImpactAdvocateRewardRedemptionState.Retrying + ) + )} + AND ${or( + sql`${impact_advocate_reward_redemptions.next_retry_at} IS NULL`, + lte(impact_advocate_reward_redemptions.next_retry_at, nowIso) + )} + ORDER BY ${impact_advocate_reward_redemptions.created_at}, ${impact_advocate_reward_redemptions.id} + LIMIT ${limit} + )` + ) + ) + .returning({ id: impact_advocate_reward_redemptions.id }); + + const summary: ImpactAdvocateRewardRedemptionDispatchSummary = { + claimed: rows.length, + redeemed: 0, + retried: 0, + failed: 0, + }; + + for (const row of rows) { + const outcome = await dispatchImpactAdvocateRewardRedemptionById(row.id); + if (outcome === 'redeemed') { + summary.redeemed++; + } else if (outcome === 'retried') { + summary.retried++; + } else { + summary.failed++; + } + } + + return summary; +} + +async function persistImpactReportReversal(params: { + reportId: string; + reason: AdverseReferralPaymentReason; + occurredAt: Date; +}): Promise { + const existing = await getImpactConversionReportById(params.reportId, db); + if (!existing) { + return false; + } + + const existingPayload = existing.response_payload ?? {}; + if (getObjectProperty(existingPayload, 'referralReversal')) { + return false; + } + + const actionId = getImpactActionIdFromResponsePayload(existingPayload); + if (!actionId) { + await db + .update(impact_conversion_reports) + .set({ + response_payload: { + ...existingPayload, + referralReversal: { + reason: params.reason, + occurredAt: params.occurredAt.toISOString(), + status: 'missing_action_id', + }, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + return false; + } + + const result = await reverseImpactAction({ actionId }); + await db + .update(impact_conversion_reports) + .set({ + response_payload: { + ...existingPayload, + referralReversal: { + reason: params.reason, + occurredAt: params.occurredAt.toISOString(), + ok: result.ok, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + responseBody: result.responseBody ?? null, + }, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + + return result.ok; +} + +export async function markPersonalKiloClawReferralPaymentAdverse(params: { + sourcePaymentId: string; + reason: AdverseReferralPaymentReason; + occurredAt: Date; +}): Promise { + let impactReportId: string | null = null; + + const summary = await db.transaction(async tx => { + const conversion = await tx.query.kiloclaw_referral_conversions.findFirst({ + where: eq(kiloclaw_referral_conversions.source_payment_id, params.sourcePaymentId), + }); + + if (!conversion) { + return { + conversionId: null, + canceledRewards: 0, + reviewRequiredRewards: 0, + }; + } + + const rewards = await tx + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.conversion_id, conversion.id)); + + let canceledRewards = 0; + let reviewRequiredRewards = 0; + for (const reward of rewards) { + if ( + reward.status === KiloClawReferralRewardStatus.Pending || + reward.status === KiloClawReferralRewardStatus.Earned + ) { + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Canceled, + review_reason: getAdversePaymentReason(params.reason), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + canceledRewards++; + continue; + } + + if (reward.status === KiloClawReferralRewardStatus.Applied) { + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.ReviewRequired, + review_reason: getAdversePaymentReason(params.reason), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + reviewRequiredRewards++; + } + } + + const report = await tx.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.conversion_id, conversion.id), + columns: { id: true }, + }); + impactReportId = report?.id ?? null; + + return { + conversionId: conversion.id, + canceledRewards, + reviewRequiredRewards, + }; + }); + + const impactActionReversed = impactReportId + ? await persistImpactReportReversal({ + reportId: impactReportId, + reason: params.reason, + occurredAt: params.occurredAt, + }) + : false; + + return { + ...summary, + impactActionReversed, + }; +} + +async function upsertReferralRelationship(params: { + refereeUserId: string; + referrerUserId: string | null; + sourceTouchId: string; + impactReferralId: string | null; + database: DatabaseClient; +}): Promise { + await params.database + .insert(kiloclaw_referrals) + .values({ + referee_user_id: params.refereeUserId, + referrer_user_id: params.referrerUserId, + source_touch_id: params.sourceTouchId, + impact_referral_id: params.impactReferralId, + }) + .onConflictDoUpdate({ + target: [kiloclaw_referrals.referee_user_id], + set: { + referrer_user_id: params.referrerUserId, + source_touch_id: params.sourceTouchId, + impact_referral_id: params.impactReferralId, + }, + }); +} + +function buildImpactReferralId(touch: KiloClawAttributionTouch): string | null { + return touch.rs_code?.trim() || touch.opaque_tracking_value?.trim() || null; +} + +async function getImpactConversionReportById( + reportId: string, + database: DatabaseClient +): Promise { + const report = await database.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.id, reportId), + }); + return report ?? null; +} + +async function persistImpactConversionReportResult(params: { + reportId: string; + result: ImpactDispatchResult; + database?: DatabaseClient; +}): Promise { + const database = getDatabaseClient(params.database); + const existing = await getImpactConversionReportById(params.reportId, database); + if (!existing) return; + + const attemptCount = existing.attempt_count + 1; + if (params.result.ok) { + if ('skipped' in params.result) { + logRewardBearingReferralConfigurationFailure({ + conversionId: existing.conversion_id ?? undefined, + }); + await database + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Failed, + attempt_count: attemptCount, + next_retry_at: null, + delivered_at: null, + response_status_code: null, + response_payload: { + error: 'missing_reward_bearing_referral_configuration', + delivery: params.result.skipped, + responseBody: params.result.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + return; + } + + await database + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Delivered, + attempt_count: attemptCount, + next_retry_at: null, + delivered_at: new Date().toISOString(), + response_status_code: null, + response_payload: { + delivery: params.result.delivery ?? null, + responseBody: params.result.responseBody ?? null, + ...('actionId' in params.result ? { actionId: params.result.actionId } : {}), + ...('submissionUri' in params.result + ? { submissionUri: params.result.submissionUri } + : {}), + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + return; + } + + const isTerminalFailure = params.result.failureKind === 'http_4xx'; + if (isTerminalFailure) { + console.error('[kiloclaw-referrals] Impact conversion report failed permanently', { + reportId: params.reportId, + conversionId: existing.conversion_id, + statusCode: params.result.statusCode ?? null, + failureKind: params.result.failureKind, + }); + } + + await database + .update(impact_conversion_reports) + .set({ + state: isTerminalFailure + ? ImpactConversionReportState.Failed + : ImpactConversionReportState.Retrying, + attempt_count: attemptCount, + next_retry_at: isTerminalFailure ? null : nextReportRetryAt(attemptCount), + response_status_code: params.result.statusCode ?? null, + response_payload: { + failureKind: params.result.failureKind, + responseBody: params.result.responseBody ?? null, + error: params.result.error ?? null, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); +} + +async function dispatchImpactConversionReportById( + reportId: string +): Promise<'delivered' | 'retried' | 'failed'> { + logImpactReferralDebug('Dispatching Impact referral conversion report', { + reportId, + }); + + const report = await getImpactConversionReportById(reportId, db); + if (!report) { + logImpactReferralDebug('Impact referral conversion report missing before dispatch', { + reportId, + }); + return 'failed'; + } + + if (!isImpactConversionPayload(report.request_payload)) { + await db + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Failed, + response_payload: { + error: + report.request_payload === null ? 'missing_request_payload' : 'invalid_request_payload', + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, report.id)); + return 'failed'; + } + const payload = report.request_payload; + + const result = await sendImpactConversionPayload(payload); + await persistImpactConversionReportResult({ reportId: report.id, result }); + const outcome = result.ok + ? 'delivered' + : result.failureKind === 'http_4xx' + ? 'failed' + : 'retried'; + logImpactReferralDebug('Impact referral conversion report dispatch result', { + reportId: report.id, + conversionId: report.conversion_id, + outcome, + ok: result.ok, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + }); + return outcome; +} + +export async function dispatchQueuedImpactConversionReports(params?: { + limit?: number; +}): Promise { + const limit = params?.limit ?? 100; + const nowIso = new Date().toISOString(); + const rows = await db + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Retrying, + next_retry_at: nextReportClaimExpiresAt(), + }) + .where( + sql`${impact_conversion_reports.id} IN ( + SELECT ${impact_conversion_reports.id} + FROM ${impact_conversion_reports} + WHERE ${or( + eq(impact_conversion_reports.state, ImpactConversionReportState.Queued), + eq(impact_conversion_reports.state, ImpactConversionReportState.Retrying) + )} + AND ${or( + sql`${impact_conversion_reports.next_retry_at} IS NULL`, + lte(impact_conversion_reports.next_retry_at, nowIso) + )} + ORDER BY ${impact_conversion_reports.created_at}, ${impact_conversion_reports.id} + LIMIT ${limit} + )` + ) + .returning({ id: impact_conversion_reports.id }); + + const summary: ImpactConversionReportDispatchSummary = { + claimed: rows.length, + delivered: 0, + retried: 0, + failed: 0, + }; + + for (const row of rows) { + const outcome = await dispatchImpactConversionReportById(row.id); + if (outcome === 'delivered') { + summary.delivered++; + } else if (outcome === 'retried') { + summary.retried++; + } else { + summary.failed++; + } + } + + return summary; +} + +export async function processPersonalKiloClawPaidConversion(params: { + userId: string; + sourcePaymentId: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; + convertedAt: Date; + qualificationContext?: PaidConversionQualificationContext; +}): Promise { + logImpactReferralDebug( + 'Processing personal KiloClaw paid conversion for Impact referral attribution', + { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + qualificationSourceType: params.qualificationContext?.sourceType ?? null, + qualificationOverrideEligible: params.qualificationContext?.overrideEligible ?? null, + } + ); + + let impactReportId: string | null = null; + const rewardBeneficiaryUserIds = new Set(); + const disposition = await db.transaction(async tx => { + const existingConversion = await tx.query.kiloclaw_referral_conversions.findFirst({ + where: eq(kiloclaw_referral_conversions.source_payment_id, params.sourcePaymentId), + }); + + if (existingConversion) { + const overrideDisqualificationReason = + params.qualificationContext?.sourceType && + params.qualificationContext.sourceType !== 'normal' + ? getQualificationDisqualificationReason(params.qualificationContext.sourceType) + : null; + const canReprocessWithAdminOverride = + params.qualificationContext?.overrideEligible === true && + existingConversion.qualified === false && + existingConversion.disqualification_reason === overrideDisqualificationReason; + + if (canReprocessWithAdminOverride) { + await tx + .delete(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.id, existingConversion.id)); + } else { + return { + shouldEnqueueAffiliateSale: + existingConversion.winning_touch_type === KiloClawReferralWinningTouchType.Affiliate, + winningTouchType: existingConversion.winning_touch_type, + conversionId: existingConversion.id, + disqualificationReason: existingConversion.disqualification_reason, + } satisfies KiloClawPaidConversionDisposition; + } + } + + const [user] = await tx + .select({ + id: kilocode_users.id, + createdAt: kilocode_users.created_at, + email: kilocode_users.google_user_email, + normalizedEmail: kilocode_users.normalized_email, + }) + .from(kilocode_users) + .where(eq(kilocode_users.id, params.userId)) + .limit(1); + + if (!user) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: 'user_missing', + } satisfies KiloClawPaidConversionDisposition; + } + + const explicitDisqualificationReason = + params.qualificationContext?.sourceType && + params.qualificationContext.sourceType !== 'normal' && + !params.qualificationContext.overrideEligible + ? getQualificationDisqualificationReason(params.qualificationContext.sourceType) + : null; + if (explicitDisqualificationReason) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: explicitDisqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + const heuristicDisqualificationReason = await getHeuristicSourcePaymentDisqualificationReason({ + sourcePaymentId: params.sourcePaymentId, + database: tx, + }); + if (heuristicDisqualificationReason && !params.qualificationContext?.overrideEligible) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: heuristicDisqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + const currentPersonalSubscription = await resolveCurrentPersonalSubscriptionRow({ + userId: params.userId, + dbOrTx: tx, + }); + if (!currentPersonalSubscription) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: referralDisqualificationReason('non_personal_subscription'), + } satisfies KiloClawPaidConversionDisposition; + } + + const hasAdminAdjustedSubscription = await hasAdminOverrideHistory({ + subscriptionId: currentPersonalSubscription.subscription.id, + database: tx, + }); + if (hasAdminAdjustedSubscription && !params.qualificationContext?.overrideEligible) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: referralDisqualificationReason('admin_adjusted_subscription'), + } satisfies KiloClawPaidConversionDisposition; + } + + const monetizedPeriods = await countMonetizedKiloClawPaymentPeriods(params.userId, tx); + if (monetizedPeriods > 1) { + const hasPreservedAffiliateSale = await hasSaleAttributedAffiliateTouch({ + userId: params.userId, + database: tx, + }); + + return { + shouldEnqueueAffiliateSale: hasPreservedAffiliateSale, + winningTouchType: hasPreservedAffiliateSale + ? KiloClawReferralWinningTouchType.Affiliate + : KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: 'not_first_paid_period', + } satisfies KiloClawPaidConversionDisposition; + } + + const touches = await findAcceptedUserTouches({ + userId: params.userId, + convertedAt: params.convertedAt, + database: tx, + }); + const resolution = resolveWinningAttributionTouch({ + touches, + convertedAt: params.convertedAt, + }); + + logImpactReferralDebug('Resolved KiloClaw Impact attribution touches for paid conversion', { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + touchCount: touches.length, + affiliateTouchCount: touches.filter( + touch => touch.touch_type === KiloClawAttributionTouchType.Affiliate + ).length, + referralTouchCount: touches.filter( + touch => touch.touch_type === KiloClawAttributionTouchType.Referral + ).length, + winner: resolution.winner, + affiliateTouchId: resolution.affiliateTouch?.id ?? null, + referralTouchId: resolution.referralTouch?.id ?? null, + }); + + if (resolution.winner === 'none') { + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: null, + winning_touch_type: KiloClawReferralWinningTouchType.None, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: referralDisqualificationReason('no_valid_attribution'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('no_valid_attribution'), + } satisfies KiloClawPaidConversionDisposition; + } + + if (resolution.winner === 'affiliate') { + await markAffiliateTouchSaleAttributed({ + database: tx, + affiliateTouchId: resolution.affiliateTouch.id, + convertedAt: params.convertedAt, + }); + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: resolution.affiliateTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Affiliate, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: referralDisqualificationReason('affiliate_won'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: true, + winningTouchType: KiloClawReferralWinningTouchType.Affiliate, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('affiliate_won'), + } satisfies KiloClawPaidConversionDisposition; + } + + const referrerUserId = await resolveReferrerUserIdFromReferralTouch({ + referralTouch: resolution.referralTouch, + database: tx, + }); + await upsertReferralRelationship({ + refereeUserId: params.userId, + referrerUserId, + sourceTouchId: resolution.referralTouch.id, + impactReferralId: buildImpactReferralId(resolution.referralTouch), + database: tx, + }); + logImpactReferralDebug('Upserted KiloClaw Impact referral relationship', { + refereeUserId: params.userId, + referrerUserId, + sourceTouchId: resolution.referralTouch.id, + impactReferralIdPresent: Boolean(buildImpactReferralId(resolution.referralTouch)?.trim()), + }); + + const deletedUser = await hasDeletedUserEmailTombstone({ + normalizedEmail: user.normalizedEmail, + database: tx, + }); + const userExistedBeforeReferral = + new Date(user.createdAt).getTime() < + new Date(resolution.referralTouch.touched_at).getTime() && + !wasReferralTouchCapturedDuringSignup({ + userCreatedAt: user.createdAt, + referralTouch: resolution.referralTouch, + }); + const isSelfReferral = referrerUserId !== null && referrerUserId === params.userId; + + if (deletedUser || userExistedBeforeReferral || !referrerUserId || isSelfReferral) { + const disqualificationReason = deletedUser + ? referralDisqualificationReason('deleted_user_tombstone') + : userExistedBeforeReferral + ? referralDisqualificationReason('existing_user_before_touch') + : !referrerUserId + ? referralDisqualificationReason('referrer_unresolved') + : referralDisqualificationReason('self_referral'); + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: disqualificationReason, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.Referral, + conversionId: conversion?.id ?? null, + disqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + if (!getRewardBearingReferralConfigurationState().isConfigured) { + const disqualificationReason = referralDisqualificationReason('missing_configuration'); + logRewardBearingReferralConfigurationFailure({ + sourcePaymentId: params.sourcePaymentId, + userId: params.userId, + }); + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: disqualificationReason, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + if (!conversion) { + throw new Error( + `Failed to create referral conversion for payment ${params.sourcePaymentId}` + ); + } + + await tx.insert(kiloclaw_referral_reward_decisions).values([ + { + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Disqualified, + reason: disqualificationReason, + months_granted: 0, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Disqualified, + reason: disqualificationReason, + months_granted: 0, + }, + ]); + + const payload = buildSalePayload({ + customerId: params.userId, + customerEmailHash: hashEmailForImpact(user.email), + eventDate: params.convertedAt, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + itemName: params.itemName, + itemSku: params.itemSku, + trackingId: null, + }); + + await tx + .insert(impact_conversion_reports) + .values({ + conversion_id: conversion.id, + dedupe_key: `impact-referral-sale:${params.sourcePaymentId}`, + action_tracker_id: IMPACT_ACTION_TRACKER_IDS.sale, + order_id: params.orderId, + state: ImpactConversionReportState.Failed, + request_payload: payload satisfies Record, + response_payload: { + error: 'missing_reward_bearing_referral_configuration', + } satisfies Record, + }) + .onConflictDoNothing({ target: [impact_conversion_reports.dedupe_key] }); + + impactReportId = null; + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.Referral, + conversionId: conversion.id, + disqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + await lockReferrerRewardCapacity(referrerUserId, tx); + const referrerGrantedMonths = await getGrantedReferrerMonths(referrerUserId, tx); + const referrerAtCap = referrerGrantedMonths >= 12; + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + qualified: true, + disqualification_reason: null, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + if (!conversion) { + throw new Error(`Failed to create referral conversion for payment ${params.sourcePaymentId}`); + } + + const refereeHasEligibleSubscription = await hasActiveEligiblePersonalSubscription( + params.userId, + tx + ); + const referrerHasEligibleSubscription = await hasActiveEligiblePersonalSubscription( + referrerUserId, + tx + ); + + const [refereeDecision, referrerDecision] = await tx + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + reason: null, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: referrerAtCap + ? KiloClawReferralDecisionOutcome.CapLimited + : KiloClawReferralDecisionOutcome.Granted, + reason: referrerAtCap ? referralDisqualificationReason('referrer_cap_reached') : null, + months_granted: referrerAtCap ? 0 : 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiary_user_id: kiloclaw_referral_reward_decisions.beneficiary_user_id, + beneficiary_role: kiloclaw_referral_reward_decisions.beneficiary_role, + outcome: kiloclaw_referral_reward_decisions.outcome, + }); + + await tx.insert(kiloclaw_referral_rewards).values( + [refereeDecision, referrerDecision] + .filter(decision => decision.outcome === KiloClawReferralDecisionOutcome.Granted) + .map(decision => ({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: decision.beneficiary_user_id, + beneficiary_role: decision.beneficiary_role, + months_granted: 1, + status: + decision.beneficiary_role === KiloClawReferralBeneficiaryRole.Referee + ? refereeHasEligibleSubscription + ? KiloClawReferralRewardStatus.Earned + : KiloClawReferralRewardStatus.Pending + : referrerHasEligibleSubscription + ? KiloClawReferralRewardStatus.Earned + : KiloClawReferralRewardStatus.Pending, + earned_at: params.convertedAt.toISOString(), + expires_at: + decision.beneficiary_role === KiloClawReferralBeneficiaryRole.Referrer && + !referrerHasEligibleSubscription + ? addMonths(params.convertedAt, 12).toISOString() + : null, + })) + ); + + const payload = buildSalePayload({ + customerId: params.userId, + customerEmailHash: hashEmailForImpact(user.email), + eventDate: params.convertedAt, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + itemName: params.itemName, + itemSku: params.itemSku, + trackingId: null, + }); + + const [report] = await tx + .insert(impact_conversion_reports) + .values({ + conversion_id: conversion.id, + dedupe_key: `impact-referral-sale:${params.sourcePaymentId}`, + action_tracker_id: IMPACT_ACTION_TRACKER_IDS.sale, + order_id: params.orderId, + state: ImpactConversionReportState.Queued, + request_payload: payload satisfies Record, + }) + .onConflictDoNothing({ target: [impact_conversion_reports.dedupe_key] }) + .returning({ id: impact_conversion_reports.id }); + + const existingReport = + report ?? + (await tx.query.impact_conversion_reports.findFirst({ + where: eq( + impact_conversion_reports.dedupe_key, + `impact-referral-sale:${params.sourcePaymentId}` + ), + columns: { id: true }, + })); + impactReportId = existingReport?.id ?? null; + rewardBeneficiaryUserIds.add(params.userId); + rewardBeneficiaryUserIds.add(referrerUserId); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.Referral, + conversionId: conversion.id, + disqualificationReason: null, + } satisfies KiloClawPaidConversionDisposition; + }); + + logImpactReferralDebug( + 'Processed personal KiloClaw paid conversion for Impact referral attribution', + { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + shouldEnqueueAffiliateSale: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + impactReportId, + rewardBeneficiaryCount: rewardBeneficiaryUserIds.size, + } + ); + + if (impactReportId) { + await dispatchImpactConversionReportById(impactReportId); + } + + if (rewardBeneficiaryUserIds.size > 0) { + try { + logImpactReferralDebug('Processing queued KiloClaw Impact referral rewards', { + sourcePaymentId: params.sourcePaymentId, + beneficiaryCount: rewardBeneficiaryUserIds.size, + }); + await processQueuedKiloClawReferralRewards({ + beneficiaryUserIds: Array.from(rewardBeneficiaryUserIds), + }); + } catch (error) { + console.error('[kiloclaw-referrals] failed to apply queued referral rewards', { + sourcePaymentId: params.sourcePaymentId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return disposition; +} diff --git a/apps/web/src/lib/kiloclaw/credit-billing.ts b/apps/web/src/lib/kiloclaw/credit-billing.ts index a52e0e2992..a697edf5a7 100644 --- a/apps/web/src/lib/kiloclaw/credit-billing.ts +++ b/apps/web/src/lib/kiloclaw/credit-billing.ts @@ -25,6 +25,7 @@ import { clearTrialInactivityStopAfterTrialTransition, } from '@/lib/kiloclaw/instance-lifecycle'; import { buildAffiliateEventDedupeKey, enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { computeUsageTriggeredMonthlyBonusDecision, maybeIssueKiloPassBonusFromUsageThreshold, @@ -306,6 +307,22 @@ async function enqueueCreditEnrollmentAffiliateEvents(params: { }); } + const conversionDisposition = await processPersonalKiloClawPaidConversion({ + userId: params.userId, + sourcePaymentId: params.saleOrderId, + orderId: params.saleOrderId, + amount: params.saleAmountMicrodollars / 1_000_000, + currencyCode: 'usd', + itemCategory: getKiloClawAffiliateItemCategory(params.plan), + itemName: getKiloClawAffiliateItemName(params.plan), + itemSku: params.saleItemSku, + convertedAt: params.eventDate, + }); + + if (!conversionDisposition.shouldEnqueueAffiliateSale) { + return; + } + await enqueueAffiliateEventForUser({ userId: params.userId, provider: 'impact', @@ -1291,17 +1308,6 @@ export async function enrollWithCredits(params: { after: mutatedSubscription, }); } - - await enqueueCreditEnrollmentAffiliateEvents({ - userId, - plan, - saleEntityId: saleDedupeKeyEntityId, - saleOrderId: deductionCategory, - saleAmountMicrodollars: costMicrodollars, - eventDate: now, - saleItemSku, - trialEndEntityId, - }); }); if (deductionWasDuplicate) { @@ -1328,6 +1334,17 @@ export async function enrollWithCredits(params: { throw new Error('Enrollment already processed for this billing period.'); } + await enqueueCreditEnrollmentAffiliateEvents({ + userId, + plan, + saleEntityId: saleDedupeKeyEntityId, + saleOrderId: deductionCategory, + saleAmountMicrodollars: costMicrodollars, + eventDate: now, + saleItemSku, + trialEndEntityId, + }); + // Step 4: Post-transaction bonus evaluation (spec rule 6) try { await maybeIssueKiloPassBonusFromUsageThreshold({ diff --git a/apps/web/src/lib/kiloclaw/stripe-handlers.ts b/apps/web/src/lib/kiloclaw/stripe-handlers.ts index d2ef82f1af..4d4d809b50 100644 --- a/apps/web/src/lib/kiloclaw/stripe-handlers.ts +++ b/apps/web/src/lib/kiloclaw/stripe-handlers.ts @@ -25,6 +25,7 @@ import { after } from 'next/server'; import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; import { client as stripe } from '@/lib/stripe-client'; import { buildAffiliateEventDedupeKey, enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { IMPACT_ORDER_ID_MACRO } from '@/lib/impact'; import { CurrentPersonalSubscriptionResolutionError, @@ -1505,24 +1506,38 @@ export async function handleKiloClawInvoicePaid(params: { invoice.status_transitions?.paid_at != null ? new Date(invoice.status_transitions.paid_at * 1000) : new Date(); - await enqueueAffiliateEventForUser({ + const conversionDisposition = await processPersonalKiloClawPaidConversion({ userId: metadata.kiloUserId, - provider: 'impact', - eventType: 'sale', - dedupeKey: buildAffiliateEventDedupeKey({ - provider: 'impact', - eventType: 'sale', - entityId: invoice.id, - }), + sourcePaymentId: invoice.id, orderId: invoice.id, amount: invoice.amount_paid / 100, currencyCode: invoice.currency ?? 'usd', - eventDate, + convertedAt: eventDate, itemCategory: getImpactItemCategory(plan), itemName: getImpactItemName(plan), itemSku: matchingPriceId, - stripeChargeId: chargeId, }); + + if (conversionDisposition.shouldEnqueueAffiliateSale) { + await enqueueAffiliateEventForUser({ + userId: metadata.kiloUserId, + provider: 'impact', + eventType: 'sale', + dedupeKey: buildAffiliateEventDedupeKey({ + provider: 'impact', + eventType: 'sale', + entityId: invoice.id, + }), + orderId: invoice.id, + amount: invoice.amount_paid / 100, + currencyCode: invoice.currency ?? 'usd', + eventDate, + itemCategory: getImpactItemCategory(plan), + itemName: getImpactItemName(plan), + itemSku: matchingPriceId, + stripeChargeId: chargeId, + }); + } } catch (error) { logWarning('Affiliate sale enqueue failed', { stripe_event_id: eventId, diff --git a/apps/web/src/lib/referral.ts b/apps/web/src/lib/referral.ts index 6217a22ea3..ee77474456 100644 --- a/apps/web/src/lib/referral.ts +++ b/apps/web/src/lib/referral.ts @@ -1,6 +1,10 @@ import 'server-only'; import assert from 'node:assert'; -import { referral_code_usages, referral_codes } from '@kilocode/db/schema'; +import { + kiloclaw_referral_conversions, + referral_code_usages, + referral_codes, +} from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { eq, and, count, sql, isNull, isNotNull } from 'drizzle-orm'; import { captureMessage } from '@sentry/nextjs'; @@ -52,6 +56,15 @@ const redeemingReferralPromoCode = referralRedeemingBonus.credit_category; const referringReferralPromoCode = referralReferringBonus.credit_category; export async function processReferralTopUp(redeemingKiloUserId: string) { + const [kiloclawReferralConversion] = await db + .select({ id: kiloclaw_referral_conversions.id }) + .from(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.referee_user_id, redeemingKiloUserId)) + .limit(1); + if (kiloclawReferralConversion) { + return; + } + // Validate referral eligibility using shared helper const validationResult = await validateReferralForRedemption(redeemingKiloUserId); if (validationResult === 'NOTFOUND') return; diff --git a/apps/web/src/lib/referrals.test.ts b/apps/web/src/lib/referrals.test.ts index a709e4eafd..8b7c08af70 100644 --- a/apps/web/src/lib/referrals.test.ts +++ b/apps/web/src/lib/referrals.test.ts @@ -1,4 +1,10 @@ -import { referral_codes, referral_code_usages, credit_transactions } from '@kilocode/db/schema'; +import { + referral_codes, + referral_code_usages, + credit_transactions, + kiloclaw_referral_conversions, + kilocode_users, +} from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { getReferralCodeForUser, @@ -16,7 +22,13 @@ describe('referrals', () => { // eslint-disable-next-line drizzle/enforce-delete-with-where await db.delete(referral_code_usages); // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(kiloclaw_referral_conversions); + // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(credit_transactions); + // eslint-disable-next-line drizzle/enforce-delete-with-where await db.delete(referral_codes); + // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(kilocode_users); }); it('should not create more than 1 code per user', async () => { @@ -314,5 +326,49 @@ describe('referrals', () => { .where(eq(credit_transactions.kilo_user_id, nonExistentUserId)); expect(creditTransactions).toHaveLength(0); }); + + it('does not grant legacy referral-code credits when a kiloclaw referral conversion exists', async () => { + const redeemingUser = await insertTestUser({ + google_user_email: 'kiloclaw-referee@example.com', + google_user_name: 'KiloClaw Referee', + google_user_image_url: 'https://example.com/referee.jpg', + stripe_customer_id: 'cus_test_kiloclaw_referee', + }); + const referringUser = await insertTestUser({ + google_user_email: 'kiloclaw-referrer@example.com', + google_user_name: 'KiloClaw Referrer', + google_user_image_url: 'https://example.com/referrer.jpg', + stripe_customer_id: 'cus_test_kiloclaw_referrer', + }); + + const { code } = await getReferralCodeForUser(referringUser.id); + await db.insert(referral_code_usages).values({ + code, + redeeming_kilo_user_id: redeemingUser.id, + referring_kilo_user_id: referringUser.id, + }); + await db.insert(kiloclaw_referral_conversions).values({ + referee_user_id: redeemingUser.id, + referrer_user_id: referringUser.id, + source_payment_id: 'kiloclaw-payment-1', + winning_touch_type: 'referral', + qualified: true, + converted_at: new Date().toISOString(), + }); + + await processReferralTopUp(redeemingUser.id); + + const legacyCredits = await db + .select() + .from(credit_transactions) + .where(eq(credit_transactions.kilo_user_id, redeemingUser.id)); + expect(legacyCredits).toHaveLength(0); + + const [usage] = await db + .select() + .from(referral_code_usages) + .where(eq(referral_code_usages.redeeming_kilo_user_id, redeemingUser.id)); + expect(usage?.paid_at).toBeNull(); + }); }); }); diff --git a/apps/web/src/lib/stripe.ts b/apps/web/src/lib/stripe.ts index 512d6744bb..c953cb9abb 100644 --- a/apps/web/src/lib/stripe.ts +++ b/apps/web/src/lib/stripe.ts @@ -55,6 +55,7 @@ import { handleKiloClawInvoicePaid, } from '@/lib/kiloclaw/stripe-handlers'; import { enqueueImpactSaleReversalForCharge } from '@/lib/affiliate-events'; +import { markPersonalKiloClawReferralPaymentAdverse } from '@/lib/kiloclaw-referrals'; import { invoiceLooksLikeKiloClawByPriceId } from '@/lib/kiloclaw/stripe-invoice-classifier.server'; import { STRIPE_TEAMS_MONTHLY_PRICE_ID, @@ -66,16 +67,23 @@ import type { OrganizationPlan, BillingCycle } from '@/lib/organizations/organiz import { isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items'; import { successResult } from '@/lib/maybe-result'; -async function isKiloClawCharge(chargeId: string): Promise { - // The `invoice` field is present at runtime on charges but removed from newer - // Stripe TypeScript definitions. Read the response as a structural type. - // Errors (Stripe outage, network) propagate so the webhook returns non-2xx and - // Stripe retries delivery — avoids both silent drops and false backlog. +type KiloClawChargeContext = { + chargeId: string; + invoiceId: string; +}; + +async function getKiloClawChargeContext(chargeId: string): Promise { const charge: Stripe.Charge & { invoice?: string | Stripe.Invoice | null } = await client.charges.retrieve(chargeId, { expand: ['invoice'] }); const invoice = charge.invoice; - if (!invoice || typeof invoice === 'string') return false; - return invoiceLooksLikeKiloClawByPriceId(invoice); + if (!invoice || typeof invoice === 'string' || !invoiceLooksLikeKiloClawByPriceId(invoice)) { + return null; + } + + return { + chargeId, + invoiceId: invoice.id, + }; } if (!APP_URL) throw new Error('APP_URL constant is not set'); @@ -786,21 +794,64 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } - // Only enqueue reversals for KiloClaw charges — those are the only ones - // that produce affiliate sale events. Non-KiloClaw disputes (Kilo Pass, - // top-ups, etc.) would otherwise accumulate in pending_impact_sale_reversals - // forever because they will never have a matching sale row. - if (!(await isKiloClawCharge(chargeId))) { + const kiloClawCharge = await getKiloClawChargeContext(chargeId); + if (!kiloClawCharge) { break; } await enqueueImpactSaleReversalForCharge({ - stripeChargeId: chargeId, + stripeChargeId: kiloClawCharge.chargeId, disputeId: dispute.id, amount: dispute.amount / 100, currency: dispute.currency, eventDate: new Date(dispute.created * 1000), }); + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: kiloClawCharge.invoiceId, + reason: 'chargeback', + occurredAt: new Date(dispute.created * 1000), + }); + break; + } + + case 'charge.refunded': { + const charge = event.data.object; + if (charge.amount_refunded <= 0) { + break; + } + + const kiloClawCharge = await getKiloClawChargeContext(charge.id); + if (!kiloClawCharge) { + break; + } + + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: kiloClawCharge.invoiceId, + reason: 'refund', + occurredAt: new Date(charge.created * 1000), + }); + break; + } + + case 'charge.updated': { + const charge = event.data.object; + const isFraudMarked = + charge.fraud_details?.user_report === 'fraudulent' || + charge.fraud_details?.stripe_report === 'fraudulent'; + if (!isFraudMarked) { + break; + } + + const kiloClawCharge = await getKiloClawChargeContext(charge.id); + if (!kiloClawCharge) { + break; + } + + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: kiloClawCharge.invoiceId, + reason: 'fraud', + occurredAt: new Date(charge.created * 1000), + }); break; } diff --git a/apps/web/src/lib/user.server.ts b/apps/web/src/lib/user.server.ts index 9991d7c849..434c6d71a5 100644 --- a/apps/web/src/lib/user.server.ts +++ b/apps/web/src/lib/user.server.ts @@ -4,7 +4,7 @@ import { validateAuthorizationHeader, JWT_TOKEN_VERSION } from './tokens'; import { NextResponse } from 'next/server'; import { cookies, headers } from 'next/headers'; -import type { CreateOrUpdateUserArgs } from './user'; +import type { CreateOrUpdateUserArgs, CreateOrUpdateUserTrackingContext } from './user'; import { findUserById, createOrUpdateUser, findAndSyncExistingUser } from './user'; import { db, readDb } from '@/lib/drizzle'; import type { @@ -30,6 +30,12 @@ import { PLATFORM } from '@/lib/integrations/core/constants'; import { verifyAndConsumeMagicLinkToken } from '@/lib/auth/magic-link-tokens'; import { redirect } from 'next/navigation'; import { IMPACT_CLICK_ID_COOKIE } from '@/lib/impact-affiliate-utils'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; +import { countryCodeFromHeaders, localeFromHeaders } from '@/lib/impact-referral'; +import { + parseImpactAffiliateTouchFromUrl, + parseImpactReferralTouchFromUrl, +} from '@/lib/impact-referral-utils'; import { isOrganizationHardLocked } from '@/lib/organizations/trial-utils'; import { getMostRecentSeatPurchase } from '@/lib/organizations/organization-seats'; import { secondsInDay } from 'date-fns/constants'; @@ -403,28 +409,81 @@ async function getSignInRedirectContext(): Promise { return parseSignInRedirectContext(raw); } -async function getAffiliateTrackingIdFromAuthFlow(): Promise { +async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): Promise<{ + affiliateTrackingId: string | null; + trackingContext: CreateOrUpdateUserTrackingContext; +}> { const cookieStore = await cookies(); - // Prefer im_ref from the callback URL (explicitly passed through the auth flow) const callbackUrlCookie = cookieStore.get('__Secure-next-auth.callback-url')?.value ?? cookieStore.get('next-auth.callback-url')?.value; + const cookieTrackingId = cookieStore.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null; if (callbackUrlCookie) { try { const callbackUrl = new URL(callbackUrlCookie, 'http://localhost'); - const imRef = callbackUrl.searchParams.get('im_ref')?.trim(); - if (imRef) return imRef; + const referralTouch = parseImpactReferralTouchFromUrl(callbackUrl); + const urlImRefParam = callbackUrl.searchParams.get('im_ref')?.trim() || null; + const ignoreUrlImRefForReferralTouch = Boolean( + referralTouch?.opaqueTrackingValue && urlImRefParam + ); + const fallbackUrl = new URL('http://localhost/users/after-sign-in'); + const affiliateTouch = ignoreUrlImRefForReferralTouch + ? cookieTrackingId && cookieTrackingId !== urlImRefParam + ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + : null + : (parseImpactAffiliateTouchFromUrl(callbackUrl) ?? + (cookieTrackingId + ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + : null)); + + logImpactReferralDebug('Auth flow parsed Impact tracking context from callback URL cookie', { + affiliateTouchPresent: Boolean(affiliateTouch), + referralTouchPresent: Boolean(referralTouch), + referralCookieValuePresent: Boolean(referralTouch?.opaqueTrackingValue), + affiliateTrackingIdPresent: Boolean(affiliateTouch?.trackingId?.trim()), + urlImRefParamPresent: Boolean(urlImRefParam), + ignoredUrlImRefForReferralTouch: ignoreUrlImRefForReferralTouch, + affiliateCookieFallbackPresent: Boolean(cookieTrackingId?.trim()), + callbackPath: callbackUrl.pathname, + }); + + return { + affiliateTrackingId: affiliateTouch?.trackingId ?? null, + trackingContext: { + affiliateTouch, + referralTouch, + locale: localeFromHeaders(requestHeaders), + countryCode: countryCodeFromHeaders(requestHeaders), + }, + }; } catch { // fall through to cookie fallback } } - // Fall back to the shared parent-domain cookie written by kilo.ai. This is - // our bridge cookie for auth redirects, not the native IR_ UTT - // cookie set by Impact itself. - return cookieStore.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null; + const fallbackUrl = new URL('http://localhost/users/after-sign-in'); + const affiliateTouch = cookieTrackingId + ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + : null; + + logImpactReferralDebug('Auth flow parsed Impact tracking context from cookie fallback', { + affiliateTouchPresent: Boolean(affiliateTouch), + referralTouchPresent: false, + affiliateTrackingIdPresent: Boolean(cookieTrackingId?.trim()), + cookieTrackingIdLength: cookieTrackingId?.length ?? 0, + }); + + return { + affiliateTrackingId: cookieTrackingId, + trackingContext: { + affiliateTouch, + referralTouch: null, + locale: localeFromHeaders(requestHeaders), + countryCode: countryCodeFromHeaders(requestHeaders), + }, + }; } type ExtendedProfile = Profile & { @@ -709,8 +768,24 @@ const authOptions: NextAuthOptions = { // For email (magic link) auth, we auto-link to existing users since magic link // is verified by email ownership const autoLinkToExistingUser = isEmailAuth || isFakeLogin; - const affiliateTrackingId = - !isAccountLinking && !isFakeLogin ? await getAffiliateTrackingIdFromAuthFlow() : null; + if (isAccountLinking) { + logImpactReferralDebug('Auth flow skipped Impact tracking context extraction', { + provider: accountInfo.provider, + isAccountLinking: Boolean(isAccountLinking), + isFakeLogin, + }); + } + + const { affiliateTrackingId, trackingContext } = !isAccountLinking + ? await getImpactTrackingContextFromAuthFlow(requestHeaders) + : { affiliateTrackingId: null, trackingContext: {} }; + + logImpactReferralDebug('Auth flow forwarding Impact tracking context to user upsert', { + provider: accountInfo.provider, + affiliateTrackingIdPresent: Boolean(affiliateTrackingId?.trim()), + affiliateTouchPresent: Boolean(trackingContext.affiliateTouch), + referralTouchPresent: Boolean(trackingContext.referralTouch), + }); const result = isAccountLinking && linkingSession ? whenOk( @@ -722,7 +797,8 @@ const authOptions: NextAuthOptions = { verifiedToken?.guid, autoLinkToExistingUser, requestHeaders, - affiliateTrackingId + affiliateTrackingId, + trackingContext ); if (result.success === false) { diff --git a/apps/web/src/lib/user.test.ts b/apps/web/src/lib/user.test.ts index 348e4a1fa8..071ab1212e 100644 --- a/apps/web/src/lib/user.test.ts +++ b/apps/web/src/lib/user.test.ts @@ -52,6 +52,17 @@ import { agent_environment_profiles, agent_environment_profile_mcp_servers, agent_environment_profile_skills, + deleted_user_email_tombstones, + kiloclaw_attribution_touches, + impact_advocate_participants, + impact_advocate_registration_attempts, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referral_reward_applications, + impact_advocate_reward_redemptions, + impact_conversion_reports, } from '@kilocode/db/schema'; import { eq, count } from 'drizzle-orm'; import { @@ -61,6 +72,7 @@ import { findUsersByIds, createOrUpdateUser, } from './user'; +import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact-referral'; import { createTestPaymentMethod } from '@/tests/helpers/payment-method.helper'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { forceImmediateExpirationRecomputation } from '@/lib/balanceCache'; @@ -86,6 +98,17 @@ describe('User', () => { await db.delete(user_auth_provider); await db.delete(user_affiliate_attributions); await db.delete(user_affiliate_events); + await db.delete(kiloclaw_attribution_touches); + await db.delete(impact_advocate_registration_attempts); + await db.delete(impact_advocate_participants); + await db.delete(impact_conversion_reports); + await db.delete(impact_advocate_reward_redemptions); + await db.delete(kiloclaw_referral_reward_applications); + await db.delete(kiloclaw_referral_rewards); + await db.delete(kiloclaw_referral_reward_decisions); + await db.delete(kiloclaw_referral_conversions); + await db.delete(kiloclaw_referrals); + await db.delete(deleted_user_email_tombstones); await db.delete(payment_methods); await db.delete(kilo_pass_issuance_items); await db.delete(kilo_pass_issuances); @@ -126,6 +149,9 @@ describe('User', () => { await db.delete(kiloclaw_subscriptions); await db.delete(kiloclaw_earlybird_purchases); await db.delete(kiloclaw_instances); + await db.delete(agent_environment_profile_skills); + await db.delete(agent_environment_profile_mcp_servers); + await db.delete(agent_environment_profiles); await db.delete(organizations); await db.delete(kilocode_users); }); @@ -378,6 +404,173 @@ describe('User', () => { expect(blockedUserAfter!.blocked_by_kilo_user_id).toBeNull(); }); + it('should tombstone normalized email hashes and delete referral program records', async () => { + const referrer = await insertTestUser({ + id: 'referrer-user', + google_user_email: 'referrer@example.com', + normalized_email: 'referrer@example.com', + }); + const user = await insertTestUser({ + id: 'referee-user', + google_user_email: 'referee@example.com', + normalized_email: 'referee@example.com', + }); + const touchId = randomUUID(); + const participantId = randomUUID(); + const conversionId = randomUUID(); + const decisionId = randomUUID(); + const rewardId = randomUUID(); + + await db.insert(kiloclaw_attribution_touches).values({ + id: touchId, + dedupe_key: 'touch-dedupe', + user_id: user.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + touched_at: '2026-04-23T00:00:00.000Z', + expires_at: '2026-05-23T00:00:00.000Z', + }); + await db.insert(impact_advocate_participants).values({ + id: participantId, + user_id: user.id, + advocate_id: user.id, + advocate_account_id: user.id, + contact_email: user.google_user_email, + registration_state: 'pending', + }); + await db.insert(impact_advocate_registration_attempts).values({ + participant_id: participantId, + dedupe_key: 'registration-dedupe', + opaque_cookie_value: 'sq-cookie', + cookie_value_length: 9, + delivery_state: 'queued', + }); + await db.insert(kiloclaw_referrals).values({ + referee_user_id: user.id, + referrer_user_id: referrer.id, + source_touch_id: touchId, + }); + await db.insert(kiloclaw_referral_conversions).values({ + id: conversionId, + referee_user_id: user.id, + referrer_user_id: referrer.id, + source_touch_id: touchId, + winning_touch_type: 'referral', + source_payment_id: 'payment-123', + qualified: true, + converted_at: '2026-04-23T00:00:00.000Z', + }); + await db.insert(kiloclaw_referral_reward_decisions).values({ + id: decisionId, + conversion_id: conversionId, + beneficiary_user_id: user.id, + beneficiary_role: 'referee', + outcome: 'granted', + months_granted: 1, + }); + await db.insert(kiloclaw_referral_rewards).values({ + id: rewardId, + conversion_id: conversionId, + decision_id: decisionId, + beneficiary_user_id: user.id, + beneficiary_role: 'referee', + months_granted: 1, + status: 'pending', + earned_at: '2026-04-23T00:00:00.000Z', + }); + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: rewardId, + beneficiary_user_id: user.id, + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-23T00:00:00.000Z', + }); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: rewardId, + dedupe_key: 'reward-redemption-dedupe', + beneficiary_user_id: user.id, + state: 'queued', + request_payload: { + lookup: { + accountId: user.google_user_email, + userId: user.google_user_email, + rewardTypeFilter: 'CREDIT', + }, + redemption: { amount: 1, unit: 'free-months' }, + }, + }); + await db.insert(impact_conversion_reports).values({ + conversion_id: conversionId, + dedupe_key: 'impact-report-dedupe', + action_tracker_id: 71659, + order_id: 'payment-123', + state: 'queued', + }); + + await softDeleteUser(user.id); + + const [tombstone] = await db + .select() + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone('referee@example.com') + ) + ); + expect(tombstone).toBeDefined(); + + const [touchCount] = await db + .select({ count: count() }) + .from(kiloclaw_attribution_touches) + .where(eq(kiloclaw_attribution_touches.user_id, user.id)); + expect(touchCount.count).toBe(0); + + const [participantCount] = await db + .select({ count: count() }) + .from(impact_advocate_participants) + .where(eq(impact_advocate_participants.user_id, user.id)); + expect(participantCount.count).toBe(0); + + const [redemptionCount] = await db + .select({ count: count() }) + .from(impact_advocate_reward_redemptions) + .where(eq(impact_advocate_reward_redemptions.beneficiary_user_id, user.id)); + expect(redemptionCount.count).toBe(0); + + const [conversionCount] = await db + .select({ count: count() }) + .from(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.referee_user_id, user.id)); + expect(conversionCount.count).toBe(0); + }); + + it('falls back to google_user_email when normalized_email is null', async () => { + // Pre-0090 users can have NULL normalized_email but a real google_user_email. + // Soft-delete must still record a tombstone so a re-registration of the + // same email cannot bypass the previously-deleted-referee guard. + const legacyUser = await insertTestUser({ + google_user_email: 'legacy-no-normalized@example.com', + normalized_email: null, + }); + + await softDeleteUser(legacyUser.id); + + const [tombstone] = await db + .select() + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone('legacy-no-normalized@example.com') + ) + ); + expect(tombstone).toBeDefined(); + }); + it('should delete auth providers', async () => { const user = await insertTestUser(); await db.insert(user_auth_provider).values({ diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index 741288cc19..a5e576451f 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -63,6 +63,15 @@ import { contributor_champion_memberships, contributor_champion_contributors, credit_campaigns, + kiloclaw_attribution_touches, + impact_advocate_participants, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referral_reward_applications, + impact_advocate_reward_redemptions, + impact_conversion_reports, } from '@kilocode/db/schema'; import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count } from 'drizzle-orm'; import { allow_fake_login, IS_DEVELOPMENT } from './constants'; @@ -82,6 +91,18 @@ import { import { normalizeEmail } from '@/lib/utils'; import { extractEmailDomain } from '@/lib/email-domain'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/affiliate-events'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; +import { + createDeletedUserEmailTombstone, + queueImpactAdvocateParticipantRegistration, + recordImpactAffiliateTouch, + recordImpactReferralTouch, +} from '@/lib/impact-referral'; +import { + redactLandingPathForLogs, + type ParsedImpactAffiliateTouch, + type ParsedImpactReferralTouch, +} from '@/lib/impact-referral-utils'; const workos = new WorkOS(WORKOS_API_KEY); @@ -223,6 +244,14 @@ export type CreateOrUpdateUserArgs = { display_name?: string | null; }; +export type CreateOrUpdateUserTrackingContext = { + affiliateTouch?: ParsedImpactAffiliateTouch | null; + referralTouch?: ParsedImpactReferralTouch | null; + anonymousId?: string | null; + locale?: string | null; + countryCode?: string | null; +}; + export async function findAndSyncExistingUser(args: CreateOrUpdateUserArgs) { const timer = createTimer(); const existing_kilo_user_id = await findUserIdByAuthProvider( @@ -298,7 +327,8 @@ export async function createOrUpdateUser( turnstile_guid: UUID | undefined, autoLinkToExistingUser: boolean = false, requestHeaders?: Headers, - affiliateTrackingId?: string | null + affiliateTrackingId?: string | null, + trackingContext?: CreateOrUpdateUserTrackingContext ): Promise> { const existingUser = await findAndSyncExistingUser(args); if (existingUser) { @@ -444,14 +474,93 @@ export async function createOrUpdateUser( }); if (affiliateTrackingId?.trim()) { - await recordAffiliateAttributionAndQueueParentEvent({ - database: tx, - userId: inserted.id, - provider: 'impact', - trackingId: affiliateTrackingId, - customerEmail: inserted.google_user_email, - eventDate: new Date(inserted.created_at), - }); + try { + logImpactReferralDebug('Signup recording Impact affiliate attribution and parent event', { + userId: inserted.id, + trackingIdLength: affiliateTrackingId.trim().length, + }); + await recordAffiliateAttributionAndQueueParentEvent({ + database: tx, + userId: inserted.id, + provider: 'impact', + trackingId: affiliateTrackingId, + customerEmail: inserted.google_user_email, + eventDate: new Date(inserted.created_at), + }); + } catch (error) { + console.error('[user] failed to persist affiliate attribution during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (trackingContext?.affiliateTouch) { + try { + logImpactReferralDebug('Signup recording Impact affiliate touch', { + userId: inserted.id, + anonymousIdPresent: Boolean(trackingContext.anonymousId?.trim()), + landingPath: redactLandingPathForLogs(trackingContext.affiliateTouch.landingPath), + trackingValueLength: trackingContext.affiliateTouch.trackingValueLength, + isTrackingValueAccepted: trackingContext.affiliateTouch.isTrackingValueAccepted, + }); + await recordImpactAffiliateTouch({ + database: tx, + userId: inserted.id, + anonymousId: trackingContext.anonymousId ?? null, + touch: trackingContext.affiliateTouch, + }); + } catch (error) { + console.error('[user] failed to record affiliate touch during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (trackingContext?.referralTouch) { + try { + logImpactReferralDebug('Signup recording Impact Advocate referral touch', { + userId: inserted.id, + anonymousIdPresent: Boolean(trackingContext.anonymousId?.trim()), + landingPath: redactLandingPathForLogs(trackingContext.referralTouch.landingPath), + rsCodePresent: Boolean(trackingContext.referralTouch.rsCode?.trim()), + trackingValueLength: trackingContext.referralTouch.trackingValueLength, + isTrackingValueAccepted: trackingContext.referralTouch.isTrackingValueAccepted, + }); + await recordImpactReferralTouch({ + database: tx, + userId: inserted.id, + anonymousId: trackingContext.anonymousId ?? null, + touch: trackingContext.referralTouch, + }); + } catch (error) { + console.error('[user] failed to record referral touch during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + logImpactReferralDebug('Signup queueing Impact Advocate participant registration', { + userId: inserted.id, + landingPath: redactLandingPathForLogs(trackingContext.referralTouch.landingPath), + localePresent: Boolean(trackingContext.locale?.trim()), + countryCode: trackingContext.countryCode ?? null, + }); + await queueImpactAdvocateParticipantRegistration({ + database: tx, + user: inserted, + referralTouch: trackingContext.referralTouch, + locale: trackingContext.locale, + countryCode: trackingContext.countryCode, + }); + } catch (error) { + console.error('[user] failed to enqueue Impact Advocate registration during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } } return successResult({ user: inserted }); @@ -687,6 +796,15 @@ export async function softDeleteUser(userId: string) { ); } + // Pre-0090 users can have NULL normalized_email but a real google_user_email. + // Fall back to google_user_email so the tombstone hash still gets recorded + // before the row below anonymizes both columns; otherwise a previously + // deleted user could re-register and qualify as a referee. + await createDeletedUserEmailTombstone({ + database: tx, + normalizedEmail: user.normalized_email ?? user.google_user_email ?? null, + }); + // ── 1. Anonymize the user row ──────────────────────────────────────── await tx .update(kilocode_users) @@ -723,6 +841,46 @@ export async function softDeleteUser(userId: string) { .delete(user_affiliate_attributions) .where(eq(user_affiliate_attributions.user_id, userId)); await tx.delete(user_affiliate_events).where(eq(user_affiliate_events.user_id, userId)); + await tx + .delete(kiloclaw_attribution_touches) + .where(eq(kiloclaw_attribution_touches.user_id, userId)); + await tx + .delete(impact_advocate_participants) + .where(eq(impact_advocate_participants.user_id, userId)); + await tx + .delete(kiloclaw_referral_reward_applications) + .where(eq(kiloclaw_referral_reward_applications.beneficiary_user_id, userId)); + await tx + .delete(impact_advocate_reward_redemptions) + .where(eq(impact_advocate_reward_redemptions.beneficiary_user_id, userId)); + await tx + .delete(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, userId)); + await tx + .delete(kiloclaw_referral_reward_decisions) + .where(eq(kiloclaw_referral_reward_decisions.beneficiary_user_id, userId)); + await tx.delete(impact_conversion_reports).where( + sql`${impact_conversion_reports.conversion_id} IN ( + SELECT c.id FROM ${kiloclaw_referral_conversions} c + WHERE c.referee_user_id = ${userId} OR c.referrer_user_id = ${userId} + )` + ); + await tx + .delete(kiloclaw_referral_conversions) + .where( + or( + eq(kiloclaw_referral_conversions.referee_user_id, userId), + eq(kiloclaw_referral_conversions.referrer_user_id, userId) + ) + ); + await tx + .delete(kiloclaw_referrals) + .where( + or( + eq(kiloclaw_referrals.referee_user_id, userId), + eq(kiloclaw_referrals.referrer_user_id, userId) + ) + ); await tx.delete(referral_codes).where(eq(referral_codes.kilo_user_id, userId)); await tx.delete(magic_link_tokens).where(eq(magic_link_tokens.email, originalEmail)); diff --git a/apps/web/src/routers/admin-router.ts b/apps/web/src/routers/admin-router.ts index 41f4b18d45..d73515480f 100644 --- a/apps/web/src/routers/admin-router.ts +++ b/apps/web/src/routers/admin-router.ts @@ -43,6 +43,7 @@ import { adminGatewayConfigRouter } from '@/routers/admin/gateway-config-router' import { adminBlacklistDomainsRouter } from '@/routers/admin/blacklist-domains-router'; import { adminBulkBlockRouter } from '@/routers/admin/bulk-block-router'; import { adminKiloPassRouter } from '@/routers/admin/kilo-pass-router'; +import { adminKiloclawReferralsRouter } from '@/routers/admin/kiloclaw-referrals-router'; import { adminShellSecurityContentRouter } from '@/routers/admin/shell-security-content-router'; import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import { adminAlertingRouter } from '@/routers/admin-alerting-router'; @@ -344,6 +345,7 @@ const CancelKiloClawSubscriptionSchema = z.object({ }); export const adminRouter = createTRPCRouter({ + kiloclawReferrals: adminKiloclawReferralsRouter, webhookTriggers: adminWebhookTriggersRouter, github: createTRPCRouter({ getKilocodeOpenPullRequestCounts: adminProcedure.query(async () => { diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts new file mode 100644 index 0000000000..75eae4970c --- /dev/null +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { eq } from 'drizzle-orm'; + +import { cleanupDbForTest, db } from '@/lib/drizzle'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + impact_advocate_reward_redemptions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + type User, +} from '@kilocode/db/schema'; + +let admin: User; +let nonAdmin: User; +let referrer: User; + +beforeEach(async () => { + await cleanupDbForTest(); + admin = await insertTestUser({ + google_user_email: `admin-referrals-${Math.random()}@admin.example.com`, + is_admin: true, + }); + nonAdmin = await insertTestUser({ + google_user_email: `not-admin-referrals-${Math.random()}@example.com`, + }); + referrer = await insertTestUser({ + google_user_email: `referrer-${Math.random()}@example.com`, + normalized_email: `referrer-${Math.random()}@example.com`, + }); +}); + +async function insertReferralInvestigationRow(params: { + refereeEmail: string; + sourcePaymentId: string; + qualified: boolean; + disqualificationReason: string | null; + reportState: 'delivered' | 'failed'; +}) { + const referee = await insertTestUser({ + google_user_email: params.refereeEmail, + normalized_email: params.refereeEmail, + }); + const [touch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `touch-${params.sourcePaymentId}`, + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'opaque-support-only', + tracking_value_length: 19, + is_tracking_value_accepted: true, + rs_code: 'RS-SUPPORT', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-01T00:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + await db.insert(kiloclaw_referrals).values({ + referee_user_id: referee.id, + referrer_user_id: referrer.id, + source_touch_id: touch.id, + impact_referral_id: 'RS-SUPPORT', + }); + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: referee.id, + referrer_user_id: referrer.id, + source_touch_id: touch.id, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: params.qualified, + disqualification_reason: params.disqualificationReason, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: referrer.id, + beneficiary_role: 'referrer', + outcome: params.qualified ? 'granted' : 'disqualified', + reason: params.disqualificationReason, + months_granted: params.qualified ? 1 : 0, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + + if (params.qualified) { + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: referrer.id, + beneficiary_role: 'referrer', + months_granted: 1, + status: 'applied', + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }) + .returning({ id: kiloclaw_referral_rewards.id }); + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: referrer.id, + subscription_id: crypto.randomUUID(), + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: reward.id, + dedupe_key: `reward-redemption-${params.sourcePaymentId}`, + beneficiary_user_id: referrer.id, + state: 'redeemed', + impact_reward_id: `impact-reward-${params.sourcePaymentId}`, + redeemed_at: '2026-04-10T00:06:00.000Z', + }); + } + + await db.insert(impact_conversion_reports).values({ + conversion_id: conversion.id, + dedupe_key: `impact-report-${params.sourcePaymentId}`, + action_tracker_id: 71659, + order_id: params.sourcePaymentId, + state: params.reportState, + request_payload: { orderId: params.sourcePaymentId }, + response_payload: { actionId: '1000.2000.3000' }, + }); + + return referee; +} + +describe('admin kiloclaw referrals investigation', () => { + it('rejects non-admin users', async () => { + const caller = await createCallerForUser(nonAdmin.id); + + await expect( + caller.admin.kiloclawReferrals.investigateReferrer({ search: referrer.id }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('searches by referrer email and returns qualified and disqualified referee diagnostics', async () => { + const qualifiedReferee = await insertReferralInvestigationRow({ + refereeEmail: `qualified-referee-${Math.random()}@example.com`, + sourcePaymentId: 'qualified-payment', + qualified: true, + disqualificationReason: null, + reportState: 'delivered', + }); + const disqualifiedReferee = await insertReferralInvestigationRow({ + refereeEmail: `disqualified-referee-${Math.random()}@example.com`, + sourcePaymentId: 'disqualified-payment', + qualified: false, + disqualificationReason: 'referral_self_referral', + reportState: 'failed', + }); + + const caller = await createCallerForUser(admin.id); + const result = await caller.admin.kiloclawReferrals.investigateReferrer({ + search: referrer.google_user_email, + }); + + expect(result.referrer).toEqual( + expect.objectContaining({ id: referrer.id, email: referrer.google_user_email }) + ); + expect(result.referrals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + referee: expect.objectContaining({ + id: qualifiedReferee.id, + email: qualifiedReferee.google_user_email, + }), + conversion: expect.objectContaining({ qualified: true, disqualificationReason: null }), + rewardDecisions: [expect.objectContaining({ outcome: 'granted', monthsGranted: 1 })], + rewardApplications: [ + expect.objectContaining({ + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }), + ], + impactReports: [expect.objectContaining({ state: 'delivered' })], + impactRewardRedemptions: [expect.objectContaining({ state: 'redeemed' })], + }), + expect.objectContaining({ + referee: expect.objectContaining({ + id: disqualifiedReferee.id, + email: disqualifiedReferee.google_user_email, + }), + conversion: expect.objectContaining({ + qualified: false, + disqualificationReason: 'referral_self_referral', + }), + rewardDecisions: [expect.objectContaining({ outcome: 'disqualified' })], + rewardApplications: [], + impactReports: [expect.objectContaining({ state: 'failed' })], + impactRewardRedemptions: [], + }), + ]) + ); + expect(result.referrals).toHaveLength(2); + + const reports = await db + .select() + .from(impact_conversion_reports) + .where(eq(impact_conversion_reports.state, 'failed')); + expect(reports).toHaveLength(1); + }); +}); diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts new file mode 100644 index 0000000000..12fa2eedc3 --- /dev/null +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts @@ -0,0 +1,417 @@ +import * as z from 'zod'; +import { TRPCError } from '@trpc/server'; +import { desc, eq, inArray, or } from 'drizzle-orm'; + +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import { + impact_advocate_reward_redemptions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + kilocode_users, +} from '@kilocode/db/schema'; + +const ReferralInvestigationInputSchema = z.object({ + search: z.string().trim().min(1), +}); + +const NullableString = z.string().nullable(); + +const ReferralInvestigationOutputSchema = z.object({ + referrer: z.object({ + id: z.string(), + email: NullableString, + name: NullableString, + }), + referrals: z.array( + z.object({ + referral: z.object({ + id: z.string().uuid(), + impactReferralId: NullableString, + createdAt: z.string(), + }), + referee: z.object({ + id: z.string(), + email: NullableString, + name: NullableString, + }), + sourceTouch: z + .object({ + id: z.string().uuid(), + provider: NullableString, + touchType: NullableString, + landingPath: NullableString, + rsCode: NullableString, + imRef: NullableString, + touchedAt: NullableString, + expiresAt: NullableString, + }) + .nullable(), + conversion: z + .object({ + id: z.string().uuid(), + winningTouchType: z.string(), + sourcePaymentId: z.string(), + qualified: z.boolean(), + disqualificationReason: NullableString, + convertedAt: z.string(), + }) + .nullable(), + rewardDecisions: z.array( + z.object({ + id: z.string().uuid(), + beneficiaryUserId: z.string(), + beneficiaryRole: z.string(), + outcome: z.string(), + reason: NullableString, + monthsGranted: z.number(), + createdAt: z.string(), + }) + ), + rewards: z.array( + z.object({ + id: z.string().uuid(), + beneficiaryUserId: z.string(), + beneficiaryRole: z.string(), + status: z.string(), + monthsGranted: z.number(), + earnedAt: z.string(), + appliedAt: NullableString, + expiresAt: NullableString, + reviewReason: NullableString, + }) + ), + rewardApplications: z.array( + z.object({ + id: z.string().uuid(), + beneficiaryUserId: z.string(), + subscriptionId: z.string().uuid().nullable(), + previousRenewalBoundary: z.string(), + newRenewalBoundary: z.string(), + appliedAt: z.string(), + }) + ), + impactReports: z.array( + z.object({ + id: z.string().uuid(), + state: z.string(), + actionTrackerId: z.number(), + orderId: z.string(), + deliveredAt: NullableString, + nextRetryAt: NullableString, + responseStatusCode: z.number().nullable(), + }) + ), + impactRewardRedemptions: z.array( + z.object({ + id: z.string().uuid(), + rewardId: z.string().uuid(), + beneficiaryUserId: z.string(), + state: z.string(), + impactRewardId: NullableString, + redeemedAt: NullableString, + nextRetryAt: NullableString, + responseStatusCode: z.number().nullable(), + }) + ), + }) + ), +}); + +type ReferralInvestigationOutput = z.infer; + +function normalizeTimestamp(value: string | null | undefined): string | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toISOString(); +} + +function listByConversionId( + rows: T[], + conversionId: string +): T[] { + return rows.filter(row => row.conversionId === conversionId); +} + +async function findReferrer(search: string) { + const normalizedSearch = search.trim().toLowerCase(); + const [referrer] = await db + .select({ + id: kilocode_users.id, + email: kilocode_users.google_user_email, + normalizedEmail: kilocode_users.normalized_email, + name: kilocode_users.google_user_name, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.id, search), + eq(kilocode_users.google_user_email, search), + eq(kilocode_users.normalized_email, normalizedSearch) + ) + ) + .limit(1); + + return referrer ?? null; +} + +async function investigateReferrer(search: string): Promise { + const referrer = await findReferrer(search); + if (!referrer) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Referrer not found.' }); + } + + const referralRows = await db + .select({ + referralId: kiloclaw_referrals.id, + impactReferralId: kiloclaw_referrals.impact_referral_id, + referralCreatedAt: kiloclaw_referrals.created_at, + refereeId: kilocode_users.id, + refereeEmail: kilocode_users.google_user_email, + refereeName: kilocode_users.google_user_name, + touchId: kiloclaw_attribution_touches.id, + touchProvider: kiloclaw_attribution_touches.provider, + touchType: kiloclaw_attribution_touches.touch_type, + landingPath: kiloclaw_attribution_touches.landing_path, + rsCode: kiloclaw_attribution_touches.rs_code, + imRef: kiloclaw_attribution_touches.im_ref, + touchedAt: kiloclaw_attribution_touches.touched_at, + expiresAt: kiloclaw_attribution_touches.expires_at, + }) + .from(kiloclaw_referrals) + .innerJoin(kilocode_users, eq(kilocode_users.id, kiloclaw_referrals.referee_user_id)) + .leftJoin( + kiloclaw_attribution_touches, + eq(kiloclaw_attribution_touches.id, kiloclaw_referrals.source_touch_id) + ) + .where(eq(kiloclaw_referrals.referrer_user_id, referrer.id)) + .orderBy(desc(kiloclaw_referrals.created_at)); + + const conversions = await db + .select({ + id: kiloclaw_referral_conversions.id, + refereeUserId: kiloclaw_referral_conversions.referee_user_id, + winningTouchType: kiloclaw_referral_conversions.winning_touch_type, + sourcePaymentId: kiloclaw_referral_conversions.source_payment_id, + qualified: kiloclaw_referral_conversions.qualified, + disqualificationReason: kiloclaw_referral_conversions.disqualification_reason, + convertedAt: kiloclaw_referral_conversions.converted_at, + }) + .from(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.referrer_user_id, referrer.id)) + .orderBy(desc(kiloclaw_referral_conversions.converted_at)); + + const conversionIds = conversions.map(conversion => conversion.id); + const rewardDecisions = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_reward_decisions.conversion_id, + id: kiloclaw_referral_reward_decisions.id, + beneficiaryUserId: kiloclaw_referral_reward_decisions.beneficiary_user_id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + outcome: kiloclaw_referral_reward_decisions.outcome, + reason: kiloclaw_referral_reward_decisions.reason, + monthsGranted: kiloclaw_referral_reward_decisions.months_granted, + createdAt: kiloclaw_referral_reward_decisions.created_at, + }) + .from(kiloclaw_referral_reward_decisions) + .where(inArray(kiloclaw_referral_reward_decisions.conversion_id, conversionIds)) + .orderBy(desc(kiloclaw_referral_reward_decisions.created_at)) + : []; + const rewards = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_rewards.conversion_id, + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + beneficiaryRole: kiloclaw_referral_rewards.beneficiary_role, + status: kiloclaw_referral_rewards.status, + monthsGranted: kiloclaw_referral_rewards.months_granted, + earnedAt: kiloclaw_referral_rewards.earned_at, + appliedAt: kiloclaw_referral_rewards.applied_at, + expiresAt: kiloclaw_referral_rewards.expires_at, + reviewReason: kiloclaw_referral_rewards.review_reason, + }) + .from(kiloclaw_referral_rewards) + .where(inArray(kiloclaw_referral_rewards.conversion_id, conversionIds)) + .orderBy(desc(kiloclaw_referral_rewards.created_at)) + : []; + const rewardApplications = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_rewards.conversion_id, + id: kiloclaw_referral_reward_applications.id, + beneficiaryUserId: kiloclaw_referral_reward_applications.beneficiary_user_id, + subscriptionId: kiloclaw_referral_reward_applications.subscription_id, + previousRenewalBoundary: kiloclaw_referral_reward_applications.previous_renewal_boundary, + newRenewalBoundary: kiloclaw_referral_reward_applications.new_renewal_boundary, + appliedAt: kiloclaw_referral_reward_applications.applied_at, + }) + .from(kiloclaw_referral_reward_applications) + .innerJoin( + kiloclaw_referral_rewards, + eq(kiloclaw_referral_rewards.id, kiloclaw_referral_reward_applications.reward_id) + ) + .where(inArray(kiloclaw_referral_rewards.conversion_id, conversionIds)) + .orderBy(desc(kiloclaw_referral_reward_applications.applied_at)) + : []; + const impactReports = conversionIds.length + ? await db + .select({ + conversionId: impact_conversion_reports.conversion_id, + id: impact_conversion_reports.id, + state: impact_conversion_reports.state, + actionTrackerId: impact_conversion_reports.action_tracker_id, + orderId: impact_conversion_reports.order_id, + deliveredAt: impact_conversion_reports.delivered_at, + nextRetryAt: impact_conversion_reports.next_retry_at, + responseStatusCode: impact_conversion_reports.response_status_code, + }) + .from(impact_conversion_reports) + .where(inArray(impact_conversion_reports.conversion_id, conversionIds)) + .orderBy(desc(impact_conversion_reports.created_at)) + : []; + const impactRewardRedemptions = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_rewards.conversion_id, + id: impact_advocate_reward_redemptions.id, + rewardId: impact_advocate_reward_redemptions.reward_id, + beneficiaryUserId: impact_advocate_reward_redemptions.beneficiary_user_id, + state: impact_advocate_reward_redemptions.state, + impactRewardId: impact_advocate_reward_redemptions.impact_reward_id, + redeemedAt: impact_advocate_reward_redemptions.redeemed_at, + nextRetryAt: impact_advocate_reward_redemptions.next_retry_at, + responseStatusCode: impact_advocate_reward_redemptions.response_status_code, + }) + .from(impact_advocate_reward_redemptions) + .innerJoin( + kiloclaw_referral_rewards, + eq(kiloclaw_referral_rewards.id, impact_advocate_reward_redemptions.reward_id) + ) + .where(inArray(kiloclaw_referral_rewards.conversion_id, conversionIds)) + .orderBy(desc(impact_advocate_reward_redemptions.created_at)) + : []; + + return { + referrer: { + id: referrer.id, + email: referrer.email, + name: referrer.name, + }, + referrals: referralRows.map(referral => { + const conversion = conversions.find(row => row.refereeUserId === referral.refereeId) ?? null; + const conversionId = conversion?.id ?? null; + + return { + referral: { + id: referral.referralId, + impactReferralId: referral.impactReferralId, + createdAt: normalizeTimestamp(referral.referralCreatedAt) ?? referral.referralCreatedAt, + }, + referee: { + id: referral.refereeId, + email: referral.refereeEmail, + name: referral.refereeName, + }, + sourceTouch: referral.touchId + ? { + id: referral.touchId, + provider: referral.touchProvider, + touchType: referral.touchType, + landingPath: referral.landingPath, + rsCode: referral.rsCode, + imRef: referral.imRef, + touchedAt: normalizeTimestamp(referral.touchedAt), + expiresAt: normalizeTimestamp(referral.expiresAt), + } + : null, + conversion: conversion + ? { + id: conversion.id, + winningTouchType: conversion.winningTouchType, + sourcePaymentId: conversion.sourcePaymentId, + qualified: conversion.qualified, + disqualificationReason: conversion.disqualificationReason, + convertedAt: normalizeTimestamp(conversion.convertedAt) ?? conversion.convertedAt, + } + : null, + rewardDecisions: conversionId + ? listByConversionId(rewardDecisions, conversionId).map(decision => ({ + id: decision.id, + beneficiaryUserId: decision.beneficiaryUserId, + beneficiaryRole: decision.beneficiaryRole, + outcome: decision.outcome, + reason: decision.reason, + monthsGranted: decision.monthsGranted, + createdAt: normalizeTimestamp(decision.createdAt) ?? decision.createdAt, + })) + : [], + rewards: conversionId + ? listByConversionId(rewards, conversionId).map(reward => ({ + id: reward.id, + beneficiaryUserId: reward.beneficiaryUserId, + beneficiaryRole: reward.beneficiaryRole, + status: reward.status, + monthsGranted: reward.monthsGranted, + earnedAt: normalizeTimestamp(reward.earnedAt) ?? reward.earnedAt, + appliedAt: normalizeTimestamp(reward.appliedAt), + expiresAt: normalizeTimestamp(reward.expiresAt), + reviewReason: reward.reviewReason, + })) + : [], + rewardApplications: conversionId + ? listByConversionId(rewardApplications, conversionId).map(application => ({ + id: application.id, + beneficiaryUserId: application.beneficiaryUserId, + subscriptionId: application.subscriptionId, + previousRenewalBoundary: + normalizeTimestamp(application.previousRenewalBoundary) ?? + application.previousRenewalBoundary, + newRenewalBoundary: + normalizeTimestamp(application.newRenewalBoundary) ?? + application.newRenewalBoundary, + appliedAt: normalizeTimestamp(application.appliedAt) ?? application.appliedAt, + })) + : [], + impactReports: conversionId + ? listByConversionId(impactReports, conversionId).map(report => ({ + id: report.id, + state: report.state, + actionTrackerId: report.actionTrackerId, + orderId: report.orderId, + deliveredAt: normalizeTimestamp(report.deliveredAt), + nextRetryAt: normalizeTimestamp(report.nextRetryAt), + responseStatusCode: report.responseStatusCode, + })) + : [], + impactRewardRedemptions: conversionId + ? listByConversionId(impactRewardRedemptions, conversionId).map(redemption => ({ + id: redemption.id, + rewardId: redemption.rewardId, + beneficiaryUserId: redemption.beneficiaryUserId, + state: redemption.state, + impactRewardId: redemption.impactRewardId, + redeemedAt: normalizeTimestamp(redemption.redeemedAt), + nextRetryAt: normalizeTimestamp(redemption.nextRetryAt), + responseStatusCode: redemption.responseStatusCode, + })) + : [], + }; + }), + }; +} + +export const adminKiloclawReferralsRouter = createTRPCRouter({ + investigateReferrer: adminProcedure + .input(ReferralInvestigationInputSchema) + .output(ReferralInvestigationOutputSchema) + .query(async ({ input }) => { + return await investigateReferrer(input.search); + }), +}); diff --git a/apps/web/src/routers/kiloclaw-billing-router.test.ts b/apps/web/src/routers/kiloclaw-billing-router.test.ts index c3ad1562b6..4d80989b9c 100644 --- a/apps/web/src/routers/kiloclaw-billing-router.test.ts +++ b/apps/web/src/routers/kiloclaw-billing-router.test.ts @@ -308,12 +308,32 @@ async function insertPersonalSubscriptionFixture(params: PersonalSubscriptionFix async function seedDeliveredImpactSignupEvent(userId: string, email: string) { const { recordAffiliateAttributionAndQueueParentEvent } = await import('@/lib/affiliate-events'); + const { recordImpactAffiliateTouch } = await import('@/lib/impact-referral'); + const eventDate = new Date('2026-04-09T10:00:00.000Z'); + const parentEvent = await recordAffiliateAttributionAndQueueParentEvent({ userId, provider: 'impact', trackingId: 'impact-click-123', customerEmail: email, - eventDate: new Date('2026-04-09T10:00:00.000Z'), + eventDate, + }); + + await recordImpactAffiliateTouch({ + userId, + touch: { + trackingId: 'impact-click-123', + trackingValueLength: 'impact-click-123'.length, + isTrackingValueAccepted: true, + landingPath: '/pricing?im_ref=impact-click-123', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: eventDate, + expiresAt: new Date('2026-05-09T10:00:00.000Z'), + }, }); expect(parentEvent).not.toBeNull(); diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 08f5c3202f..ab7cdf70ab 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -12,6 +12,12 @@ import { kiloclaw_inbound_email_aliases, kiloclaw_inbound_email_reserved_aliases, kiloclaw_instances, + kiloclaw_attribution_touches, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, kiloclaw_subscription_change_log, kiloclaw_subscriptions, } from '@kilocode/db/schema'; @@ -116,6 +122,59 @@ let createCaller: (ctx: { user: Awaited> }) => startedAt: number | null; }>; destroy: () => Promise<{ ok: true }>; + getActivePersonalBillingStatus: () => Promise<{ + subscription: { + referralRewards: { + totalAppliedMonths: number; + applications: Array<{ + role: string; + appliedAt: string; + monthsGranted: number; + previousRenewalBoundary: string; + newRenewalBoundary: string; + }>; + }; + } | null; + }>; + getSubscriptionDetail: (input: { instanceId: string }) => Promise<{ + referralRewards: { + totalAppliedMonths: number; + applications: Array<{ + role: string; + appliedAt: string; + monthsGranted: number; + previousRenewalBoundary: string; + newRenewalBoundary: string; + }>; + }; + }>; + getReferralRewardSummary: () => Promise<{ + rewards: Array<{ + role: string; + status: string; + monthsGranted: number; + earnedAt: string; + appliedAt: string | null; + application: { + previousRenewalBoundary: string; + newRenewalBoundary: string; + } | null; + }>; + totals: { + totalRewards: number; + pendingRewards: number; + totalAppliedMonths: number; + }; + referredPeople: Array<{ + maskedEmail: string | null; + state: string; + rewardGranted: boolean; + }>; + pendingRewardAction: { + showStartReactivateCta: boolean; + pendingRewardCount: number; + }; + }>; }; const kiloclawClientMock = jest.requireMock( '@/lib/kiloclaw/kiloclaw-internal-client' @@ -507,6 +566,390 @@ describe('kiloclawRouter start', () => { }); }); +describe('kiloclawRouter getActivePersonalBillingStatus referral rewards', () => { + beforeEach(async () => { + await cleanupDbForTest(); + }); + + async function insertActivePersonalSubscription(userId: string) { + const instanceId = crypto.randomUUID(); + await db.insert(kiloclaw_instances).values({ + id: instanceId, + user_id: userId, + sandbox_id: `ki_${instanceId.replace(/-/g, '')}`, + }); + const [subscription] = await db + .insert(kiloclaw_subscriptions) + .values({ + user_id: userId, + instance_id: instanceId, + payment_source: 'credits', + plan: 'standard', + status: 'active', + current_period_start: '2026-04-01T00:00:00.000Z', + current_period_end: '2026-06-01T00:00:00.000Z', + credit_renewal_at: '2026-06-01T00:00:00.000Z', + }) + .returning({ id: kiloclaw_subscriptions.id, instanceId: kiloclaw_subscriptions.instance_id }); + + return { subscriptionId: subscription.id, instanceId: subscription.instanceId ?? instanceId }; + } + + async function insertAppliedReferralReward(params: { + beneficiaryUserId: string; + subscriptionId: string; + role: 'referrer' | 'referee'; + sourcePaymentId: string; + }) { + const referee = await insertTestUser({ + google_user_email: `kiloclaw-reward-referee-${Math.random()}@example.com`, + }); + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: referee.id, + referrer_user_id: params.role === 'referrer' ? params.beneficiaryUserId : null, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: true, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: params.role, + outcome: 'granted', + months_granted: 1, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: params.role, + months_granted: 1, + status: 'applied', + applies_to_subscription_id: params.subscriptionId, + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }) + .returning({ id: kiloclaw_referral_rewards.id }); + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: params.beneficiaryUserId, + subscription_id: params.subscriptionId, + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + } + + it('returns applied referral rewards for the active personal subscription', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-reward-status-${Math.random()}@example.com`, + }); + const { subscriptionId, instanceId } = await insertActivePersonalSubscription(user.id); + await insertAppliedReferralReward({ + beneficiaryUserId: user.id, + subscriptionId, + role: 'referrer', + sourcePaymentId: `kiloclaw-subscription:${instanceId}:2026-04`, + }); + + const billing = await createCaller({ user }).getActivePersonalBillingStatus(); + + expect(billing.subscription?.referralRewards).toEqual({ + totalAppliedMonths: 1, + applications: [ + { + role: 'referrer', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + ], + }); + }); + + it('returns an empty referral reward summary when no applications belong to the subscription owner', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-empty-reward-status-${Math.random()}@example.com`, + }); + const otherUser = await insertTestUser({ + google_user_email: `kiloclaw-other-reward-status-${Math.random()}@example.com`, + }); + const { subscriptionId, instanceId } = await insertActivePersonalSubscription(user.id); + await insertAppliedReferralReward({ + beneficiaryUserId: otherUser.id, + subscriptionId, + role: 'referrer', + sourcePaymentId: `kiloclaw-subscription:${instanceId}:other-user`, + }); + + const billing = await createCaller({ user }).getActivePersonalBillingStatus(); + + expect(billing.subscription?.referralRewards).toEqual({ + totalAppliedMonths: 0, + applications: [], + }); + }); + + it('returns rewards for an explicitly viewed user-owned subscription', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-detail-reward-status-${Math.random()}@example.com`, + }); + const { subscriptionId, instanceId } = await insertActivePersonalSubscription(user.id); + await insertAppliedReferralReward({ + beneficiaryUserId: user.id, + subscriptionId, + role: 'referee', + sourcePaymentId: `kiloclaw-subscription:${instanceId}:detail`, + }); + + const detail = await createCaller({ user }).getSubscriptionDetail({ instanceId }); + + expect(detail.referralRewards).toEqual({ + totalAppliedMonths: 1, + applications: [ + { + role: 'referee', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + ], + }); + }); +}); + +describe('kiloclawRouter getReferralRewardSummary', () => { + beforeEach(async () => { + await cleanupDbForTest(); + }); + + async function insertRewardSummaryReward(params: { + userId: string; + role: 'referrer' | 'referee'; + status: 'pending' | 'applied'; + sourcePaymentId: string; + }) { + const otherUser = await insertTestUser({ + google_user_email: `kiloclaw-summary-other-${Math.random()}@example.com`, + }); + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.role === 'referee' ? params.userId : otherUser.id, + referrer_user_id: params.role === 'referrer' ? params.userId : otherUser.id, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: true, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: params.role, + outcome: 'granted', + months_granted: 1, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.userId, + beneficiary_role: params.role, + months_granted: 1, + status: params.status, + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: params.status === 'applied' ? '2026-04-10T00:05:00.000Z' : null, + }) + .returning({ id: kiloclaw_referral_rewards.id }); + + if (params.status === 'applied') { + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: params.userId, + subscription_id: crypto.randomUUID(), + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + } + } + + async function insertReferralRelationship(params: { + referrerId: string; + refereeEmail: string; + sourcePaymentId?: string; + qualified?: boolean; + disqualificationReason?: string | null; + }) { + const referee = await insertTestUser({ + google_user_email: params.refereeEmail, + normalized_email: params.refereeEmail, + }); + const [touch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `summary-relationship-touch-${params.refereeEmail}`, + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'private-cookie-value', + tracking_value_length: 20, + is_tracking_value_accepted: true, + rs_code: 'RS-CUSTOMER', + im_ref: 'private-impact-click', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-01T00:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + await db.insert(kiloclaw_referrals).values({ + referee_user_id: referee.id, + referrer_user_id: params.referrerId, + source_touch_id: touch.id, + impact_referral_id: 'RS-CUSTOMER', + }); + + if (params.sourcePaymentId) { + await db.insert(kiloclaw_referral_conversions).values({ + referee_user_id: referee.id, + referrer_user_id: params.referrerId, + source_touch_id: touch.id, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: params.qualified ?? true, + disqualification_reason: params.disqualificationReason ?? null, + converted_at: '2026-04-10T00:00:00.000Z', + }); + } + } + + it('lists current-user rewards with status and application details', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-summary-${Math.random()}@example.com`, + }); + const otherUser = await insertTestUser({ + google_user_email: `kiloclaw-summary-hidden-${Math.random()}@example.com`, + }); + await insertRewardSummaryReward({ + userId: user.id, + role: 'referrer', + status: 'applied', + sourcePaymentId: 'summary-applied', + }); + await insertRewardSummaryReward({ + userId: user.id, + role: 'referee', + status: 'pending', + sourcePaymentId: 'summary-pending', + }); + await insertRewardSummaryReward({ + userId: otherUser.id, + role: 'referrer', + status: 'applied', + sourcePaymentId: 'summary-other', + }); + + const summary = await createCaller({ user }).getReferralRewardSummary(); + + expect(summary.totals).toEqual({ + totalRewards: 2, + pendingRewards: 1, + totalAppliedMonths: 1, + }); + expect(summary.rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'referrer', + status: 'applied', + monthsGranted: 1, + earnedAt: '2026-04-10T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + application: expect.objectContaining({ + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }), + }), + expect.objectContaining({ + role: 'referee', + status: 'pending', + monthsGranted: 1, + application: null, + }), + ]) + ); + }); + + it('returns customer-safe referred people and pending reward CTA state', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-summary-referrer-${Math.random()}@example.com`, + }); + await insertRewardSummaryReward({ + userId: user.id, + role: 'referrer', + status: 'pending', + sourcePaymentId: 'summary-pending-cta', + }); + await insertReferralRelationship({ + referrerId: user.id, + refereeEmail: 'qualified-referee@example.com', + sourcePaymentId: 'summary-qualified-referee', + qualified: true, + }); + await insertReferralRelationship({ + referrerId: user.id, + refereeEmail: 'signed-up-referee@example.com', + }); + await insertReferralRelationship({ + referrerId: user.id, + refereeEmail: 'disqualified-referee@example.com', + sourcePaymentId: 'summary-disqualified-referee', + qualified: false, + disqualificationReason: 'referral_self_referral', + }); + + const summary = await createCaller({ user }).getReferralRewardSummary(); + + expect(summary.pendingRewardAction).toEqual({ + showStartReactivateCta: true, + pendingRewardCount: 1, + }); + expect(summary.referredPeople).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + maskedEmail: 'q***@example.com', + state: 'reward_granted', + rewardGranted: true, + }), + expect.objectContaining({ + maskedEmail: 's***@example.com', + state: 'waiting_for_paid_conversion', + rewardGranted: false, + }), + ]) + ); + expect(summary.referredPeople).toHaveLength(2); + expect(JSON.stringify(summary.referredPeople)).not.toContain('qualified-referee@example.com'); + expect(JSON.stringify(summary.referredPeople)).not.toContain('private-cookie-value'); + expect(JSON.stringify(summary.referredPeople)).not.toContain('private-impact-click'); + expect(JSON.stringify(summary.referredPeople)).not.toContain('referral_self_referral'); + }); +}); + describe('kiloclawRouter destroy', () => { beforeEach(async () => { await cleanupDbForTest(); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index d8588670e0..f9c443d325 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -27,6 +27,10 @@ import { kiloclaw_earlybird_purchases, kiloclaw_subscriptions, kiloclaw_instances, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_rewards, kiloclaw_email_log, kiloclaw_cli_runs, kiloclaw_scheduled_actions, @@ -38,7 +42,7 @@ import { credit_transactions, organizations, } from '@kilocode/db/schema'; -import { and, eq, ne, asc, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; +import { and, asc, eq, ne, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { deleteWorkerTrigger } from '@/lib/webhook-agent/webhook-agent-client'; import { sentryLogger } from '@/lib/utils.server'; @@ -1017,6 +1021,31 @@ function createNoInstanceStatus(userId: string, workerUrl: string): KiloClawDash } satisfies KiloClawDashboardStatus; } +function isFakeSeedInstance(instance: ActiveKiloClawInstance): boolean { + return instance.sandboxId.startsWith('ki_fake_'); +} + +function createFakeSeedInstanceStatus( + instance: ActiveKiloClawInstance, + workerUrl: string +): KiloClawDashboardStatus { + return { + ...createNoInstanceStatus(instance.userId, workerUrl), + sandboxId: instance.sandboxId, + provider: 'docker-local', + runtimeId: instance.sandboxId, + storageId: instance.sandboxId, + region: 'local', + status: 'stopped', + provisionedAt: Date.now(), + trackedImageTag: 'fake-local-instance', + workerUrl, + name: instance.name ?? null, + instanceId: instance.id, + inboundEmailEnabled: instance.inboundEmailEnabled, + } satisfies KiloClawDashboardStatus; +} + function sanitizeKiloCodeConfigResponse( response: KiloCodeConfigResponse ): KiloCodeConfigPublicResponse { @@ -1376,6 +1405,28 @@ const KiloclawInstanceSwitchPlanInputSchema = z.object({ toPlan: z.enum(['commit', 'standard']), }); const KiloclawActivationStateSchema = z.enum(['pending_settlement', 'activated']); +const KiloclawReferralRewardRoleSchema = z.enum(['referrer', 'referee']); +const KiloclawReferralRewardStatusSchema = z.enum([ + 'pending', + 'earned', + 'applied', + 'expired', + 'canceled', + 'reversed', + 'review_required', +]); +const KiloclawSubscriptionReferralRewardsSchema = z.object({ + totalAppliedMonths: z.number(), + applications: z.array( + z.object({ + role: KiloclawReferralRewardRoleSchema, + appliedAt: z.string(), + monthsGranted: z.number(), + previousRenewalBoundary: z.string(), + newRenewalBoundary: z.string(), + }) + ), +}); const KiloclawPersonalSubscriptionSchema = z.object({ instanceId: z.string().uuid(), @@ -1401,10 +1452,49 @@ const KiloclawPersonalSubscriptionSchema = z.object({ hasStripeFunding: z.boolean(), renewalCostMicrodollars: z.number().nullable(), showConversionPrompt: z.boolean(), + referralRewards: KiloclawSubscriptionReferralRewardsSchema, }); const KiloclawPersonalSubscriptionsOutputSchema = z.object({ subscriptions: z.array(KiloclawPersonalSubscriptionSchema), }); +const KiloclawReferredPersonStateSchema = z.enum(['reward_granted', 'waiting_for_paid_conversion']); +const KiloclawReferralRewardSummarySchema = z.object({ + totals: z.object({ + totalRewards: z.number(), + pendingRewards: z.number(), + totalAppliedMonths: z.number(), + }), + pendingRewardAction: z.object({ + showStartReactivateCta: z.boolean(), + pendingRewardCount: z.number(), + }), + referredPeople: z.array( + z.object({ + maskedEmail: z.string().nullable(), + state: KiloclawReferredPersonStateSchema, + rewardGranted: z.boolean(), + }) + ), + rewards: z.array( + z.object({ + role: KiloclawReferralRewardRoleSchema, + status: KiloclawReferralRewardStatusSchema, + monthsGranted: z.number(), + earnedAt: z.string(), + appliedAt: z.string().nullable(), + expiresAt: z.string().nullable(), + reviewReason: z.string().nullable(), + application: z + .object({ + appliedAt: z.string(), + subscriptionId: z.string().uuid().nullable(), + previousRenewalBoundary: z.string(), + newRenewalBoundary: z.string(), + }) + .nullable(), + }) + ), +}); const KiloclawBillingHistoryInputSchema = KiloclawInstanceInputSchema.extend({ cursor: z.string().optional(), @@ -1415,6 +1505,11 @@ const KiloclawCustomerPortalInputSchema = KiloclawInstanceInputSchema.extend({ }); const KiloclawMutationResultSchema = z.object({ success: z.boolean() }); +type KiloclawSubscriptionReferralRewards = z.infer< + typeof KiloclawSubscriptionReferralRewardsSchema +>; +type KiloclawReferralRewardSummary = z.infer; + type KiloclawPersonalSubscriptionRow = { subscription: typeof kiloclaw_subscriptions.$inferSelect; instance: { @@ -1494,6 +1589,12 @@ async function getPersonalBillingStatus(user: { hasPaidSubscription && (sub.plan === 'standard' || sub.plan === 'commit') ? KILOCLAW_PLAN_COST_MICRODOLLARS[sub.plan] : null; + const referralRewards = hasPaidSubscription + ? await getAppliedReferralRewardsForSubscription({ + userId: user.id, + subscriptionId: sub.id, + }) + : null; const subscriptionData = hasPaidSubscription ? { @@ -1511,6 +1612,7 @@ async function getPersonalBillingStatus(user: { renewalCostMicrodollars, showConversionPrompt, pendingConversion: sub.pending_conversion ?? false, + referralRewards: referralRewards ?? { totalAppliedMonths: 0, applications: [] }, } : null; @@ -1607,6 +1709,19 @@ async function getPersonalBillingStatus(user: { } satisfies ClawBillingStatus; } +function maskCustomerEmail(email: string | null): string | null { + if (!email) return null; + const [localPart, domain] = email.toLowerCase().split('@'); + if (!localPart || !domain) return null; + return `${localPart.slice(0, 1)}***@${domain}`; +} + +function referredPersonState( + qualified: boolean | null +): 'reward_granted' | 'waiting_for_paid_conversion' { + return qualified === true ? 'reward_granted' : 'waiting_for_paid_conversion'; +} + function summarizePersonalBillingStatus(billing: ClawBillingStatus) { const hasActiveInstance = billing.instance?.exists ?? false; const activeInstanceId = hasActiveInstance ? (billing.instance?.id ?? null) : null; @@ -1622,12 +1737,170 @@ function summarizePersonalBillingStatus(billing: ClawBillingStatus) { }; } -function serializeKiloclawPersonalSubscription( +async function hasEligiblePersonalSubscriptionForReferralReward(userId: string): Promise { + let currentRow: Awaited>; + try { + currentRow = await resolveCurrentPersonalSubscriptionRow({ userId, dbOrTx: db }); + } catch (error) { + mapCurrentSubscriptionResolutionError(error); + } + const subscription = currentRow?.subscription; + if (!subscription) return false; + + return ( + subscription.plan !== 'trial' && + subscription.status === 'active' && + !subscription.cancel_at_period_end && + subscription.suspended_at === null && + subscription.past_due_since === null + ); +} + +async function getCustomerReferralRewardSummary( + userId: string +): Promise { + const rows = await db + .select({ + role: kiloclaw_referral_rewards.beneficiary_role, + status: kiloclaw_referral_rewards.status, + monthsGranted: kiloclaw_referral_rewards.months_granted, + earnedAt: kiloclaw_referral_rewards.earned_at, + appliedAt: kiloclaw_referral_rewards.applied_at, + expiresAt: kiloclaw_referral_rewards.expires_at, + reviewReason: kiloclaw_referral_rewards.review_reason, + applicationAppliedAt: kiloclaw_referral_reward_applications.applied_at, + applicationSubscriptionId: kiloclaw_referral_reward_applications.subscription_id, + previousRenewalBoundary: kiloclaw_referral_reward_applications.previous_renewal_boundary, + newRenewalBoundary: kiloclaw_referral_reward_applications.new_renewal_boundary, + }) + .from(kiloclaw_referral_rewards) + .leftJoin( + kiloclaw_referral_reward_applications, + eq(kiloclaw_referral_reward_applications.reward_id, kiloclaw_referral_rewards.id) + ) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, userId)) + .orderBy(desc(kiloclaw_referral_rewards.earned_at), desc(kiloclaw_referral_rewards.created_at)); + + const rewards = rows.map(row => ({ + role: row.role, + status: row.status, + monthsGranted: row.monthsGranted, + earnedAt: normalizeTimestamp(row.earnedAt) ?? row.earnedAt, + appliedAt: normalizeTimestamp(row.appliedAt), + expiresAt: normalizeTimestamp(row.expiresAt), + reviewReason: row.reviewReason, + application: + row.applicationAppliedAt && row.previousRenewalBoundary && row.newRenewalBoundary + ? { + appliedAt: normalizeTimestamp(row.applicationAppliedAt) ?? row.applicationAppliedAt, + subscriptionId: row.applicationSubscriptionId, + previousRenewalBoundary: + normalizeTimestamp(row.previousRenewalBoundary) ?? row.previousRenewalBoundary, + newRenewalBoundary: + normalizeTimestamp(row.newRenewalBoundary) ?? row.newRenewalBoundary, + } + : null, + })); + + const referredRows = await db + .select({ + refereeEmail: kilocode_users.google_user_email, + qualified: kiloclaw_referral_conversions.qualified, + }) + .from(kiloclaw_referrals) + .innerJoin(kilocode_users, eq(kilocode_users.id, kiloclaw_referrals.referee_user_id)) + .leftJoin( + kiloclaw_referral_conversions, + and( + eq(kiloclaw_referral_conversions.referee_user_id, kiloclaw_referrals.referee_user_id), + eq(kiloclaw_referral_conversions.referrer_user_id, userId) + ) + ) + .where(eq(kiloclaw_referrals.referrer_user_id, userId)) + .orderBy(desc(kiloclaw_referrals.created_at)); + const referredPeople = referredRows + .filter(row => row.qualified !== false) + .map(row => ({ + maskedEmail: maskCustomerEmail(row.refereeEmail), + state: referredPersonState(row.qualified), + rewardGranted: row.qualified === true, + })); + const pendingRewardCount = rewards.filter( + reward => reward.role === 'referrer' && reward.status === 'pending' + ).length; + const hasEligibleSubscription = await hasEligiblePersonalSubscriptionForReferralReward(userId); + + return { + totals: { + totalRewards: rewards.length, + pendingRewards: rewards.filter(reward => reward.status === 'pending').length, + totalAppliedMonths: rewards + .filter(reward => reward.status === 'applied') + .reduce((total, reward) => total + reward.monthsGranted, 0), + }, + pendingRewardAction: { + showStartReactivateCta: pendingRewardCount > 0 && !hasEligibleSubscription, + pendingRewardCount, + }, + referredPeople, + rewards, + }; +} + +async function getAppliedReferralRewardsForSubscription(params: { + userId: string; + subscriptionId: string; +}): Promise { + const rows = await db + .select({ + role: kiloclaw_referral_rewards.beneficiary_role, + appliedAt: kiloclaw_referral_reward_applications.applied_at, + monthsGranted: kiloclaw_referral_rewards.months_granted, + previousRenewalBoundary: kiloclaw_referral_reward_applications.previous_renewal_boundary, + newRenewalBoundary: kiloclaw_referral_reward_applications.new_renewal_boundary, + }) + .from(kiloclaw_referral_reward_applications) + .innerJoin( + kiloclaw_referral_rewards, + eq(kiloclaw_referral_rewards.id, kiloclaw_referral_reward_applications.reward_id) + ) + .where( + and( + eq(kiloclaw_referral_reward_applications.subscription_id, params.subscriptionId), + eq(kiloclaw_referral_reward_applications.beneficiary_user_id, params.userId), + eq(kiloclaw_referral_rewards.applies_to_subscription_id, params.subscriptionId), + eq(kiloclaw_referral_rewards.beneficiary_user_id, params.userId), + eq(kiloclaw_referral_rewards.status, 'applied') + ) + ) + .orderBy( + asc(kiloclaw_referral_reward_applications.applied_at), + asc(kiloclaw_referral_reward_applications.created_at) + ); + + return { + totalAppliedMonths: rows.reduce((total, row) => total + row.monthsGranted, 0), + applications: rows.map(row => ({ + role: row.role, + appliedAt: normalizeTimestamp(row.appliedAt) ?? row.appliedAt, + monthsGranted: row.monthsGranted, + previousRenewalBoundary: + normalizeTimestamp(row.previousRenewalBoundary) ?? row.previousRenewalBoundary, + newRenewalBoundary: normalizeTimestamp(row.newRenewalBoundary) ?? row.newRenewalBoundary, + })), + }; +} + +async function serializeKiloclawPersonalSubscription( row: KiloclawPersonalSubscriptionRow, hasActiveKiloPass: boolean ) { const hasStripeFunding = Boolean(row.subscription.stripe_subscription_id); const activationState = getKiloClawSubscriptionActivationState(row.subscription); + const referralRewards = await getAppliedReferralRewardsForSubscription({ + userId: row.subscription.user_id, + subscriptionId: row.subscription.id, + }); return { instanceId: row.instance.id, @@ -1653,6 +1926,7 @@ function serializeKiloclawPersonalSubscription( hasStripeFunding, renewalCostMicrodollars: getKiloclawRenewalCostMicrodollars(row.subscription.plan), showConversionPrompt: hasStripeFunding && hasActiveKiloPass, + referralRewards, }; } @@ -2456,6 +2730,10 @@ export const kiloclawRouter = createTRPCRouter({ return createNoInstanceStatus(ctx.user.id, legacyWorkerUrl); } + if (isFakeSeedInstance(instance)) { + return createFakeSeedInstanceStatus(instance, legacyWorkerUrl); + } + const client = new KiloClawInternalClient(); const [status, inboundEmailAddress, scheduledAction] = await Promise.all([ client.getStatus(ctx.user.id, workerInstanceId(instance)), @@ -3735,6 +4013,12 @@ export const kiloclawRouter = createTRPCRouter({ return summarizePersonalBillingStatus(billing); }), + getReferralRewardSummary: baseProcedure + .output(KiloclawReferralRewardSummarySchema) + .query(async ({ ctx }) => { + return await getCustomerReferralRewardSummary(ctx.user.id); + }), + // ── Personal subscription management ───────────────────────────────── listPersonalSubscriptions: baseProcedure @@ -3746,8 +4030,8 @@ export const kiloclawRouter = createTRPCRouter({ ]); return { - subscriptions: rows.map(row => - serializeKiloclawPersonalSubscription(row, hasActiveKiloPass) + subscriptions: await Promise.all( + rows.map(row => serializeKiloclawPersonalSubscription(row, hasActiveKiloPass)) ), }; }), @@ -3761,7 +4045,7 @@ export const kiloclawRouter = createTRPCRouter({ getHasActiveKiloPassForUser(ctx.user.id), ]); - return serializeKiloclawPersonalSubscription(row, hasActiveKiloPass); + return await serializeKiloclawPersonalSubscription(row, hasActiveKiloPass); }), getBillingHistory: baseProcedure diff --git a/apps/web/src/types/impact.d.ts b/apps/web/src/types/impact.d.ts index 008e1f8936..80d7c50917 100644 --- a/apps/web/src/types/impact.d.ts +++ b/apps/web/src/types/impact.d.ts @@ -1,9 +1,23 @@ +import type { DetailedHTMLProps, HTMLAttributes, ReactNode } from 'react'; + declare global { interface Window { ire?: (...args: unknown[]) => void; + impactToken?: string; } function ire(...args: unknown[]): void; } +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'impact-embed': DetailedHTMLProps, HTMLElement> & { + widget?: string; + children?: ReactNode; + }; + } + } +} + export {}; diff --git a/dev/local/scripts/start-tunnel.ts b/dev/local/scripts/start-tunnel.ts index 933009f74d..b417c40c21 100644 --- a/dev/local/scripts/start-tunnel.ts +++ b/dev/local/scripts/start-tunnel.ts @@ -5,10 +5,16 @@ import * as path from 'node:path'; const repoRoot = path.resolve(import.meta.dirname, '../../..'); const devVarsPath = path.join(repoRoot, 'services/kiloclaw/.dev.vars'); +const envLocalPath = path.join(repoRoot, '.env.local'); type TunnelConfig = { tunnelName: string; + tunnelConfig: string; tunnelHostname: string; + appHostname: string; + kiloclawHostname: string; + kiloChatHostname: string; + updateAppEnv: boolean; }; const DOCKER_HOST_INTERNAL = 'host.docker.internal'; @@ -39,29 +45,100 @@ function loadTunnelConfig(): TunnelConfig { return { tunnelName: merged['TUNNEL_NAME'] ?? '', + tunnelConfig: expandHome(merged['TUNNEL_CONFIG'] ?? ''), tunnelHostname: merged['TUNNEL_HOSTNAME'] ?? '', + appHostname: merged['TUNNEL_APP_HOSTNAME'] ?? merged['TUNNEL_HOSTNAME'] ?? '', + kiloclawHostname: merged['TUNNEL_KILOCLAW_HOSTNAME'] ?? merged['TUNNEL_HOSTNAME'] ?? '', + kiloChatHostname: merged['TUNNEL_KILOCHAT_HOSTNAME'] ?? merged['TUNNEL_HOSTNAME'] ?? '', + updateAppEnv: merged['TUNNEL_UPDATE_APP_ENV'] !== 'false', }; } +function expandHome(value: string): string { + if (value === '~') return os.homedir(); + if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2)); + return value; +} + +function originFromHostname(value: string): string | null { + const trimmed = value.trim().replace(/\/+$/, ''); + if (!trimmed) return null; + + try { + if (/^https?:\/\//.test(trimmed)) { + return new URL(trimmed).origin; + } + return new URL(`https://${trimmed}`).origin; + } catch { + throw new Error(`Invalid tunnel hostname: ${value}`); + } +} + function updateEnvValue(filePath: string, key: string, value: string): void { let content = ''; if (fs.existsSync(filePath)) { content = fs.readFileSync(filePath, 'utf-8'); } - const activePattern = new RegExp(`^${key}=.*`, 'm'); - const commentedPattern = new RegExp(`^# ${key}=.*`, 'm'); + const lines = content.split('\n'); + const nextLines: string[] = []; + let replaced = false; + let replacedComment = false; + + for (const [index, line] of lines.entries()) { + if (line === '' && index === lines.length - 1) continue; + const trimmed = line.trimStart(); + if (!replaced && line.startsWith(`${key}=`)) { + nextLines.push(`${key}=${value}`); + replaced = true; + continue; + } + if (!replaced && !replacedComment && trimmed.startsWith(`# ${key}=`)) { + nextLines.push(`${key}=${value}`); + replaced = true; + replacedComment = true; + continue; + } + if (line.startsWith(`${key}=`)) { + continue; + } + nextLines.push(line); + } + + if (!replaced) { + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') { + nextLines.push(''); + } + nextLines.push(`${key}=${value}`); + } + + fs.writeFileSync(filePath, `${nextLines.join('\n').replace(/\n+$/, '')}\n`); +} - if (activePattern.test(content)) { - content = content.replace(activePattern, `${key}=${value}`); - } else if (commentedPattern.test(content)) { - content = content.replace(commentedPattern, `${key}=${value}`); - } else { - content = content.endsWith('\n') || content === '' ? content : content + '\n'; - content += `${key}=${value}\n`; +function readEnvValueFromFile(filePath: string, key: string): string | null { + if (!fs.existsSync(filePath)) return null; + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + if (line.startsWith(`${key}=`)) { + return line.slice(key.length + 1); + } } + return null; +} - fs.writeFileSync(filePath, content); +function appendEnvListValues(filePath: string, key: string, values: string[]): void { + const existing = readEnvValueFromFile(filePath, key); + const entries = new Set( + (existing ?? '') + .split(',') + .map(value => value.trim()) + .filter(Boolean) + ); + for (const value of values) { + if (value) entries.add(value); + } + if (entries.size > 0) { + updateEnvValue(filePath, key, [...entries].join(',')); + } } function loadKiloClawProvider(): string { @@ -155,36 +232,71 @@ if (provider === DOCKER_LOCAL_PROVIDER) { console.log(`Set KILOCHAT_BASE_URL=${kiloChatUrl}`); setInterval(() => undefined, 60_000); -} else { - if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { - console.error( - 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' - ); - process.exit(1); - } +} else if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { + console.error( + 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' + ); + process.exit(1); } -if (provider !== DOCKER_LOCAL_PROVIDER && config.tunnelName) { +if (provider !== DOCKER_LOCAL_PROVIDER && (config.tunnelName || config.tunnelConfig)) { const label = 'kiloclaw-tunnel'; - const child = spawn('cloudflared', ['tunnel', 'run', config.tunnelName], { + const args = config.tunnelConfig + ? ['tunnel', '--config', config.tunnelConfig, 'run'] + : ['tunnel', 'run', config.tunnelName]; + const child = spawn('cloudflared', args, { stdio: ['ignore', 'pipe', 'pipe'], }); trackChild(label, child); - console.log(`Named tunnel: ${config.tunnelName} -> ${config.tunnelHostname}`); + const appOrigin = originFromHostname(config.appHostname); + const kiloclawOrigin = originFromHostname(config.kiloclawHostname); + const kiloChatOrigin = originFromHostname(config.kiloChatHostname); - if (config.tunnelHostname) { - const apiUrl = `https://${config.tunnelHostname}/api/gateway/`; - const checkinUrl = `https://${config.tunnelHostname}/api/controller/checkin`; - const kiloChatUrl = `https://${config.tunnelHostname}`; + console.log( + `Named tunnel: ${config.tunnelConfig || config.tunnelName}` + + `${appOrigin ? `\n app -> ${appOrigin}` : ''}` + + `${kiloclawOrigin ? `\n kiloclaw -> ${kiloclawOrigin}` : ''}` + + `${kiloChatOrigin ? `\n kilochat -> ${kiloChatOrigin}` : ''}` + ); + + if (appOrigin) { + const apiUrl = `${appOrigin}/api/gateway/`; + updateEnvValue(devVarsPath, 'BACKEND_API_URL', appOrigin); updateEnvValue(devVarsPath, 'KILOCODE_API_BASE_URL', apiUrl); - updateEnvValue(devVarsPath, 'KILOCLAW_CHECKIN_URL', checkinUrl); - updateEnvValue(devVarsPath, 'KILOCHAT_BASE_URL', kiloChatUrl); + console.log(`Set BACKEND_API_URL=${appOrigin}`); console.log(`Set KILOCODE_API_BASE_URL=${apiUrl}`); + + if (config.updateAppEnv) { + updateEnvValue(envLocalPath, 'APP_URL_OVERRIDE', appOrigin); + updateEnvValue(envLocalPath, 'NEXTAUTH_URL', appOrigin); + console.log(`Set APP_URL_OVERRIDE=${appOrigin}`); + console.log(`Set NEXTAUTH_URL=${appOrigin}`); + } + } + + if (kiloclawOrigin) { + const checkinUrl = `${kiloclawOrigin}/api/controller/checkin`; + updateEnvValue(devVarsPath, 'KILOCLAW_CHECKIN_URL', checkinUrl); console.log(`Set KILOCLAW_CHECKIN_URL=${checkinUrl}`); - console.log(`Set KILOCHAT_BASE_URL=${kiloChatUrl}`); + + if (config.updateAppEnv) { + updateEnvValue(envLocalPath, 'KILOCLAW_API_URL', kiloclawOrigin); + console.log(`Set KILOCLAW_API_URL=${kiloclawOrigin}`); + } + } + + if (kiloChatOrigin) { + updateEnvValue(devVarsPath, 'KILOCHAT_BASE_URL', kiloChatOrigin); + console.log(`Set KILOCHAT_BASE_URL=${kiloChatOrigin}`); } + appendEnvListValues( + devVarsPath, + 'OPENCLAW_ALLOWED_ORIGINS', + [appOrigin, kiloclawOrigin, kiloChatOrigin].filter((origin): origin is string => !!origin) + ); + child.stdout.on('data', data => prefixAndWrite(label, data)); child.stderr.on('data', data => prefixAndWrite(label, data)); child.on('close', code => exitAndStopOthers(label, code)); diff --git a/dev/seed/AGENTS.md b/dev/seed/AGENTS.md index 4eb482c53f..26d25f6e84 100644 --- a/dev/seed/AGENTS.md +++ b/dev/seed/AGENTS.md @@ -11,6 +11,7 @@ dev/seed/ preflight.ts Import FIRST; mutates process.env from argv. db.ts Lazy drizzle client. stripe.ts Lazy Stripe test-mode client/customer helpers. + kiloclaw-referrals.ts KiloClaw referral fixtures/helpers. /.ts Topic module. Scope = folder; topic = filename. ``` @@ -40,7 +41,7 @@ Topic files MUST: - `type SeedResult = Record`: flat JSON primitives only; no nested objects; stringify Dates. - Return every id/email/handle/balance needed by follow-up commands; the runner formats all output from this object. - Support `--help`/`-h` via local `printUsage()` and early return. -- Reset only their own data at start for idempotent reruns. Delete by stable ids/emails, stable sandbox prefixes, or `dev-seed:` category prefixes. +- Reset only their own data at start for idempotent reruns. Referral seeds use `cleanupKiloClawReferralSeedScenario`; ad-hoc topics delete by stable ids/emails, stable sandbox prefixes, or `dev-seed:` category prefixes. - Avoid module-level side effects (DB writes/network); no-args listing imports modules. Topic files SHOULD: @@ -64,6 +65,7 @@ Topic files MUST NOT: - `deleteSeedStripeCustomer(id)`: rollback helper; swallows "no such customer". - `lib/stripe.ts` rejects missing/non-`sk_test_...` `STRIPE_SECRET_KEY`. - For seeded users used by Stripe-touching app code (`/profile`, billing pages, KiloClaw subscriptions), create a real Stripe customer. Never use `cus_seed_...`: it causes `StripeInvalidRequestError: No such customer` 400s. Order matches `createUserOnSignIn`: create Stripe customer, insert DB row, delete Stripe customer in `catch` on insert failure. +- `lib/kiloclaw-referrals.ts`: deterministic id/email/payment-id factories (`seedUserId`, `seedEmail`, `seedOpaqueReferralIdentifier`, ...), `cleanupKiloClawReferralSeedScenario`, and `insertSeedUsers`. Use for new referral scenarios so cleanup stays consistent. ## Direct user inserts diff --git a/dev/seed/kiloclaw/referrals-cap-boundary.ts b/dev/seed/kiloclaw/referrals-cap-boundary.ts new file mode 100644 index 0000000000..53c8ddbcee --- /dev/null +++ b/dev/seed/kiloclaw/referrals-cap-boundary.ts @@ -0,0 +1,278 @@ +import { + kiloclaw_referral_conversions, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, +} from '@kilocode/db/schema'; +import { and, eq } from 'drizzle-orm'; +import { + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; +import { + addDays, + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedOpaqueReferralIdentifier, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-cap-boundary'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const currentRefereeUserId = seedUserId(SCENARIO, 'current-referee'); +const currentRefereeEmail = seedEmail(SCENARIO, 'current-referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); + +function buildHistoricalReferee(i: number) { + const role = `historical-referee-${i}`; + return { + id: seedUserId(SCENARIO, role), + email: seedEmail(SCENARIO, role), + name: `Seed Cap Boundary Referee ${i}`, + }; +} + +export async function run(): Promise { + const db = getSeedDb(); + const historicalReferees = Array.from({ length: 12 }, (_, index) => + buildHistoricalReferee(index + 1) + ); + const userIds = [ + referrerUserId, + currentRefereeUserId, + ...historicalReferees.map(user => user.id), + ]; + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer plus historical and current referees`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Cap Boundary Referrer', + }, + { + id: currentRefereeUserId, + email: currentRefereeEmail, + name: 'Seed Cap Boundary Current Referee', + }, + ...historicalReferees, + ]); + await assertUserCount({ userIds, expectedCount: userIds.length }); + + console.log( + `[${SEED_SCOPE}] Inserting the referrer participant and an active personal subscription` + ); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-01-01T12:00:00.000Z', + }); + const { subscription: referrerSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Cap Boundary Referrer', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-12-01T00:00:00.000Z', + currentPeriodEnd: '2027-01-01T00:00:00.000Z', + creditRenewalAt: '2027-01-01T00:00:00.000Z', + }); + + console.log(`[${SEED_SCOPE}] Inserting 12 previously granted referrer months`); + for (const [index, historicalReferee] of historicalReferees.entries()) { + const convertedAt = addDays('2026-01-15T12:00:00.000Z', index * 20); + const [referral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: historicalReferee.id, + referrer_user_id: referrerUserId, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: historicalReferee.id, + referrer_user_id: referrerUserId, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: seedSourcePaymentId(SCENARIO, `historical-${index + 1}`), + qualified: true, + converted_at: convertedAt, + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [refereeDecision, referrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: historicalReferee.id, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const refereeDecisionId = + refereeDecision.beneficiaryRole === 'referee' ? refereeDecision.id : referrerDecision.id; + const referrerDecisionId = + refereeDecision.beneficiaryRole === 'referrer' ? refereeDecision.id : referrerDecision.id; + + await db.insert(kiloclaw_referral_rewards).values([ + { + conversion_id: conversion.id, + decision_id: refereeDecisionId, + beneficiary_user_id: historicalReferee.id, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Earned, + earned_at: convertedAt, + }, + { + conversion_id: conversion.id, + decision_id: referrerDecisionId, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + months_granted: 1, + status: KiloClawReferralRewardStatus.Earned, + applies_to_subscription_id: referrerSubscription.id, + earned_at: convertedAt, + }, + ]); + + console.log( + ` - historical referee ${index + 1}: referral ${referral.id}, conversion ${conversion.id}` + ); + } + + console.log( + `[${SEED_SCOPE}] Inserting the next qualified conversion with a cap-limited referrer outcome` + ); + const [currentReferral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: currentRefereeUserId, + referrer_user_id: referrerUserId, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [currentConversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: currentRefereeUserId, + referrer_user_id: referrerUserId, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: seedSourcePaymentId(SCENARIO, 'current-13th'), + qualified: true, + converted_at: '2026-12-15T12:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [currentRefereeDecision, currentReferrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: currentConversion.id, + beneficiary_user_id: currentRefereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: currentConversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.CapLimited, + reason: 'referral_referrer_cap_reached', + months_granted: 0, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const currentGrantedDecisionId = + currentRefereeDecision.beneficiaryRole === 'referee' + ? currentRefereeDecision.id + : currentReferrerDecision.id; + const currentCapLimitedDecisionId = + currentRefereeDecision.beneficiaryRole === 'referrer' + ? currentRefereeDecision.id + : currentReferrerDecision.id; + + await db.insert(kiloclaw_referral_rewards).values({ + conversion_id: currentConversion.id, + decision_id: currentGrantedDecisionId, + beneficiary_user_id: currentRefereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Earned, + earned_at: '2026-12-15T12:00:00.000Z', + }); + + const referrerGrantedMonths = await db + .select({ id: kiloclaw_referral_reward_decisions.id }) + .from(kiloclaw_referral_reward_decisions) + .where( + and( + eq(kiloclaw_referral_reward_decisions.beneficiary_user_id, referrerUserId), + eq( + kiloclaw_referral_reward_decisions.beneficiary_role, + KiloClawReferralBeneficiaryRole.Referrer + ), + eq(kiloclaw_referral_reward_decisions.outcome, KiloClawReferralDecisionOutcome.Granted) + ) + ); + + console.log(''); + console.log('This fixture represents:'); + console.log('- 12 previously granted referrer reward months already recorded'); + console.log('- a 13th qualified referral where the referee still gets a reward'); + console.log( + '- the referrer decision is recorded as cap-limited with no extra referrer reward row' + ); + + return { + referrerUserId, + currentRefereeUserId, + currentReferralId: currentReferral.id, + currentConversionId: currentConversion.id, + currentCapLimitedDecisionId, + referrerSubscriptionId: referrerSubscription.id, + grantedReferrerMonthsBeforeCapDecision: referrerGrantedMonths.length, + }; +} diff --git a/dev/seed/kiloclaw/referrals-happy-path.ts b/dev/seed/kiloclaw/referrals-happy-path.ts new file mode 100644 index 0000000000..43736ce0ec --- /dev/null +++ b/dev/seed/kiloclaw/referrals-happy-path.ts @@ -0,0 +1,318 @@ +import { + credit_transactions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, +} from '@kilocode/db/schema'; +import { + ImpactConversionReportState, + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; +import { + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertAppliedRewardChangeLog, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedLabelForScenario, + seedOpaqueReferralIdentifier, + seedOrderId, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-happy-path'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; + +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const refereeUserId = seedUserId(SCENARIO, 'referee'); +const userIds = [referrerUserId, refereeUserId]; +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const refereeEmail = seedEmail(SCENARIO, 'referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); +const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'period-1'); +const orderId = seedOrderId(SCENARIO, 'period-1'); +const touchedAtAffiliate = '2026-04-10T12:00:00.000Z'; +const touchedAtReferral = '2026-04-11T09:00:00.000Z'; +const convertedAt = '2026-04-15T16:30:00.000Z'; +const previousRenewalBoundary = '2026-05-01T00:00:00.000Z'; +const newRenewalBoundary = '2026-06-01T00:00:00.000Z'; + +export async function run(): Promise { + const db = getSeedDb(); + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer and referee users`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Referrals Happy Referrer', + }, + { + id: refereeUserId, + email: refereeEmail, + name: 'Seed Referrals Happy Referee', + }, + ]); + await assertUserCount({ userIds, expectedCount: 2 }); + + console.log(`[${SEED_SCOPE}] Inserting active personal subscriptions with applied reward state`); + const { subscription: referrerSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Happy Path Referrer', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: newRenewalBoundary, + creditRenewalAt: newRenewalBoundary, + }); + const { subscription: refereeSubscription } = await insertPersonalSubscription({ + userId: refereeUserId, + sandboxId: `sandbox-${refereeUserId}`, + name: 'Seed Happy Path Referee', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: newRenewalBoundary, + creditRenewalAt: newRenewalBoundary, + }); + + console.log(`[${SEED_SCOPE}] Inserting registered Advocate participant for the referrer`); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-04-01T12:00:00.000Z', + }); + + console.log(`[${SEED_SCOPE}] Inserting affiliate and referral touches for the referee`); + const [affiliateTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:affiliate`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Affiliate, + provider: KiloClawAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:im-ref`, + tracking_value_length: 42, + is_tracking_value_accepted: true, + im_ref: `${seedLabelForScenario(SCENARIO)}:im-ref`, + landing_path: '/pricing?im_ref=seed', + touched_at: touchedAtAffiliate, + expires_at: '2026-05-10T12:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + const [referralTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:referral`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:cookie`, + tracking_value_length: 40, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + rs_share_medium: 'copy_link', + rs_engagement_medium: 'direct', + landing_path: '/pricing?_saasquatch=seed', + touched_at: touchedAtReferral, + expires_at: '2026-05-11T09:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + console.log(`[${SEED_SCOPE}] Materializing the qualified referral conversion and rewards`); + const [referral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: sourcePaymentId, + qualified: true, + converted_at: convertedAt, + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [refereeDecision, referrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const refereeDecisionId = + refereeDecision.beneficiaryRole === 'referee' ? refereeDecision.id : referrerDecision.id; + const referrerDecisionId = + refereeDecision.beneficiaryRole === 'referrer' ? refereeDecision.id : referrerDecision.id; + + const [refereeReward, referrerReward] = await db + .insert(kiloclaw_referral_rewards) + .values([ + { + conversion_id: conversion.id, + decision_id: refereeDecisionId, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: refereeSubscription.id, + earned_at: convertedAt, + applied_at: '2026-04-15T16:40:00.000Z', + }, + { + conversion_id: conversion.id, + decision_id: referrerDecisionId, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + months_granted: 1, + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: referrerSubscription.id, + earned_at: convertedAt, + applied_at: '2026-04-15T16:42:00.000Z', + }, + ]) + .returning({ + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + }); + + const refereeRewardId = + refereeReward.beneficiaryUserId === refereeUserId ? refereeReward.id : referrerReward.id; + const referrerRewardId = + refereeReward.beneficiaryUserId === referrerUserId ? refereeReward.id : referrerReward.id; + + await db.insert(kiloclaw_referral_reward_applications).values([ + { + reward_id: refereeRewardId, + beneficiary_user_id: refereeUserId, + subscription_id: refereeSubscription.id, + previous_renewal_boundary: previousRenewalBoundary, + new_renewal_boundary: newRenewalBoundary, + local_operation_id: `${seedLabelForScenario(SCENARIO)}:reward:referee`, + applied_at: '2026-04-15T16:40:00.000Z', + }, + { + reward_id: referrerRewardId, + beneficiary_user_id: referrerUserId, + subscription_id: referrerSubscription.id, + previous_renewal_boundary: previousRenewalBoundary, + new_renewal_boundary: newRenewalBoundary, + local_operation_id: `${seedLabelForScenario(SCENARIO)}:reward:referrer`, + applied_at: '2026-04-15T16:42:00.000Z', + }, + ]); + + await insertAppliedRewardChangeLog({ + subscription: refereeSubscription, + previousBoundary: previousRenewalBoundary, + newBoundary: newRenewalBoundary, + }); + await insertAppliedRewardChangeLog({ + subscription: referrerSubscription, + previousBoundary: previousRenewalBoundary, + newBoundary: newRenewalBoundary, + }); + + await db.insert(credit_transactions).values({ + kilo_user_id: refereeUserId, + amount_microdollars: -2000000, + is_free: false, + description: 'Seed referral happy path paid period', + credit_category: sourcePaymentId, + created_at: convertedAt, + }); + + await db.insert(impact_conversion_reports).values({ + conversion_id: conversion.id, + dedupe_key: `${seedLabelForScenario(SCENARIO)}:impact-report`, + action_tracker_id: 71659, + order_id: orderId, + state: ImpactConversionReportState.Delivered, + request_payload: { + orderId, + sourcePaymentId, + scenario: SCENARIO, + winningTouchType: 'referral', + }, + response_payload: { + ok: true, + actionId: `${seedLabelForScenario(SCENARIO)}:impact-action`, + }, + response_status_code: 200, + attempt_count: 1, + delivered_at: '2026-04-15T16:35:00.000Z', + }); + + console.log(''); + console.log('This fixture represents:'); + console.log('- affiliate touch first, referral touch second'); + console.log('- no prior affiliate SALE attribution'); + console.log('- referral wins at first paid conversion'); + console.log('- both rewards already applied to personal credits subscriptions'); + console.log('- Impact sale report already delivered'); + + return { + referrerUserId, + refereeUserId, + referralId: referral.id, + conversionId: conversion.id, + affiliateTouchId: affiliateTouch.id, + referralTouchId: referralTouch.id, + sourcePaymentId, + orderId, + referrerSubscriptionId: referrerSubscription.id, + refereeSubscriptionId: refereeSubscription.id, + }; +} diff --git a/dev/seed/kiloclaw/referrals-pending-referrer.ts b/dev/seed/kiloclaw/referrals-pending-referrer.ts new file mode 100644 index 0000000000..912684e295 --- /dev/null +++ b/dev/seed/kiloclaw/referrals-pending-referrer.ts @@ -0,0 +1,282 @@ +import { + credit_transactions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, +} from '@kilocode/db/schema'; +import { + ImpactConversionReportState, + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; +import { + addMonthsUtc, + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertAppliedRewardChangeLog, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedLabelForScenario, + seedOpaqueReferralIdentifier, + seedOrderId, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-pending-referrer'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; + +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const refereeUserId = seedUserId(SCENARIO, 'referee'); +const userIds = [referrerUserId, refereeUserId]; +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const refereeEmail = seedEmail(SCENARIO, 'referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); +const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'period-1'); +const orderId = seedOrderId(SCENARIO, 'period-1'); +const convertedAt = '2026-04-15T16:30:00.000Z'; +const previousRenewalBoundary = '2026-05-01T00:00:00.000Z'; +const newRenewalBoundary = '2026-06-01T00:00:00.000Z'; + +export async function run(): Promise { + const db = getSeedDb(); + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer and referee users`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Pending Referrer', + }, + { + id: refereeUserId, + email: refereeEmail, + name: 'Seed Pending Referee', + }, + ]); + await assertUserCount({ userIds, expectedCount: 2 }); + + console.log( + `[${SEED_SCOPE}] Inserting a trial subscription for the referrer and an active subscription for the referee` + ); + const { subscription: referrerTrialSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Pending Referrer Trial', + plan: 'trial', + status: 'trialing', + trialStartedAt: '2026-04-01T00:00:00.000Z', + trialEndsAt: '2026-04-20T00:00:00.000Z', + }); + const { subscription: refereeSubscription } = await insertPersonalSubscription({ + userId: refereeUserId, + sandboxId: `sandbox-${refereeUserId}`, + name: 'Seed Pending Referee Active', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: newRenewalBoundary, + creditRenewalAt: newRenewalBoundary, + }); + + console.log(`[${SEED_SCOPE}] Inserting registered Advocate participant for the referrer`); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-04-01T12:00:00.000Z', + }); + + const [referralTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:referral`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:cookie`, + tracking_value_length: 44, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + rs_share_medium: 'email', + rs_engagement_medium: 'share', + landing_path: '/pricing?_saasquatch=seed', + touched_at: '2026-04-11T09:00:00.000Z', + expires_at: '2026-05-11T09:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + console.log( + `[${SEED_SCOPE}] Materializing the qualified referral with a pending referrer reward` + ); + const [referral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: sourcePaymentId, + qualified: true, + converted_at: convertedAt, + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [refereeDecision, referrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const refereeDecisionId = + refereeDecision.beneficiaryRole === 'referee' ? refereeDecision.id : referrerDecision.id; + const referrerDecisionId = + refereeDecision.beneficiaryRole === 'referrer' ? refereeDecision.id : referrerDecision.id; + + const [refereeReward, referrerReward] = await db + .insert(kiloclaw_referral_rewards) + .values([ + { + conversion_id: conversion.id, + decision_id: refereeDecisionId, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: refereeSubscription.id, + earned_at: convertedAt, + applied_at: '2026-04-15T16:41:00.000Z', + }, + { + conversion_id: conversion.id, + decision_id: referrerDecisionId, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + months_granted: 1, + status: KiloClawReferralRewardStatus.Pending, + earned_at: convertedAt, + expires_at: addMonthsUtc(convertedAt, 12), + }, + ]) + .returning({ + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + }); + + const refereeRewardId = + refereeReward.beneficiaryUserId === refereeUserId ? refereeReward.id : referrerReward.id; + const referrerRewardId = + refereeReward.beneficiaryUserId === referrerUserId ? refereeReward.id : referrerReward.id; + + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: refereeRewardId, + beneficiary_user_id: refereeUserId, + subscription_id: refereeSubscription.id, + previous_renewal_boundary: previousRenewalBoundary, + new_renewal_boundary: newRenewalBoundary, + local_operation_id: `${seedLabelForScenario(SCENARIO)}:reward:referee`, + applied_at: '2026-04-15T16:41:00.000Z', + }); + + await insertAppliedRewardChangeLog({ + subscription: refereeSubscription, + previousBoundary: previousRenewalBoundary, + newBoundary: newRenewalBoundary, + }); + + await db.insert(credit_transactions).values({ + kilo_user_id: refereeUserId, + amount_microdollars: -2000000, + is_free: false, + description: 'Seed pending-referrer paid period', + credit_category: sourcePaymentId, + created_at: convertedAt, + }); + + await db.insert(impact_conversion_reports).values({ + conversion_id: conversion.id, + dedupe_key: `${seedLabelForScenario(SCENARIO)}:impact-report`, + action_tracker_id: 71659, + order_id: orderId, + state: ImpactConversionReportState.Delivered, + request_payload: { + orderId, + sourcePaymentId, + scenario: SCENARIO, + rewardState: 'pending-referrer', + }, + response_payload: { + ok: true, + actionId: `${seedLabelForScenario(SCENARIO)}:impact-action`, + }, + response_status_code: 200, + attempt_count: 1, + delivered_at: '2026-04-15T16:35:00.000Z', + }); + + console.log(''); + console.log('This fixture represents:'); + console.log('- a qualified referral conversion'); + console.log('- the referee reward already applied'); + console.log('- the referrer still on a trial, so their reward remains pending'); + console.log('- the pending reward already has a 12-month expiration timestamp'); + + return { + referrerUserId, + refereeUserId, + referralId: referral.id, + conversionId: conversion.id, + sourcePaymentId, + orderId, + referrerTrialSubscriptionId: referrerTrialSubscription.id, + refereeSubscriptionId: refereeSubscription.id, + pendingReferrerRewardId: referrerRewardId, + }; +} diff --git a/dev/seed/kiloclaw/referrals-support-override.ts b/dev/seed/kiloclaw/referrals-support-override.ts new file mode 100644 index 0000000000..163bdbc621 --- /dev/null +++ b/dev/seed/kiloclaw/referrals-support-override.ts @@ -0,0 +1,173 @@ +import { credit_transactions, kiloclaw_attribution_touches } from '@kilocode/db/schema'; +import { + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; +import { + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedLabelForScenario, + seedOpaqueReferralIdentifier, + seedOrderId, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-support-override'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const refereeUserId = seedUserId(SCENARIO, 'referee'); +const userIds = [referrerUserId, refereeUserId]; +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const refereeEmail = seedEmail(SCENARIO, 'referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); +const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'manual-adjustment'); +const orderId = seedOrderId(SCENARIO, 'manual-adjustment'); +const convertedAt = '2026-04-15T16:30:00.000Z'; + +export async function run(): Promise { + const db = getSeedDb(); + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer and referee users`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Support Override Referrer', + }, + { + id: refereeUserId, + email: refereeEmail, + name: 'Seed Support Override Referee', + }, + ]); + await assertUserCount({ userIds, expectedCount: 2 }); + + console.log(`[${SEED_SCOPE}] Inserting active personal subscriptions for both users`); + const { subscription: referrerSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Support Override Referrer', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: '2026-05-01T00:00:00.000Z', + creditRenewalAt: '2026-05-01T00:00:00.000Z', + }); + const { subscription: refereeSubscription } = await insertPersonalSubscription({ + userId: refereeUserId, + sandboxId: `sandbox-${refereeUserId}`, + name: 'Seed Support Override Referee', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: '2026-05-01T00:00:00.000Z', + creditRenewalAt: '2026-05-01T00:00:00.000Z', + }); + + console.log( + `[${SEED_SCOPE}] Inserting the referrer participant and a valid referral touch on the referee` + ); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-04-01T12:00:00.000Z', + }); + + const [affiliateTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:affiliate`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Affiliate, + provider: KiloClawAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:im-ref`, + tracking_value_length: 50, + is_tracking_value_accepted: true, + im_ref: `${seedLabelForScenario(SCENARIO)}:im-ref`, + landing_path: '/pricing?im_ref=seed', + touched_at: '2026-04-10T12:00:00.000Z', + expires_at: '2026-05-10T12:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + const [referralTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:referral`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:cookie`, + tracking_value_length: 48, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + rs_share_medium: 'support', + rs_engagement_medium: 'manual', + landing_path: '/pricing?_saasquatch=seed', + touched_at: '2026-04-11T09:00:00.000Z', + expires_at: '2026-05-11T09:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + console.log( + `[${SEED_SCOPE}] Inserting a manual-adjustment payment record ready for admin override processing` + ); + await db.insert(credit_transactions).values({ + kilo_user_id: refereeUserId, + amount_microdollars: -2000000, + is_free: false, + description: 'Manual seed adjustment for referral override verification', + credit_category: sourcePaymentId, + created_at: convertedAt, + }); + + console.log(''); + console.log('This fixture represents:'); + console.log('- a valid referral touch that would normally win over the affiliate touch'); + console.log('- a source payment that heuristically looks like a manual adjustment'); + console.log('- no conversion rows yet, so an authorized operator can test the override flow'); + console.log(''); + console.log('Suggested next step (requires an authenticated admin session):'); + console.log( + ` curl -X POST http://localhost:3000/admin/api/users/${refereeUserId}/kiloclaw-referral-eligibility \\\n -H 'content-type: application/json' \\\n --data '${JSON.stringify( + { + sourcePaymentId, + orderId, + amount: 20, + currencyCode: 'USD', + itemCategory: 'kiloclaw_subscription', + itemName: 'KiloClaw Standard', + convertedAt, + sourceType: 'manual_adjustment', + } + )}'` + ); + + return { + referrerUserId, + refereeUserId, + referrerSubscriptionId: referrerSubscription.id, + refereeSubscriptionId: refereeSubscription.id, + affiliateTouchId: affiliateTouch.id, + referralTouchId: referralTouch.id, + sourcePaymentId, + orderId, + }; +} diff --git a/dev/seed/lib/kiloclaw-referrals.ts b/dev/seed/lib/kiloclaw-referrals.ts new file mode 100644 index 0000000000..3c35a3f49d --- /dev/null +++ b/dev/seed/lib/kiloclaw-referrals.ts @@ -0,0 +1,364 @@ +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; +import type { KiloClawPaymentSource, KiloClawSubscriptionStatus } from '@kilocode/db/schema-types'; +import { + credit_transactions, + impact_advocate_participants, + impact_advocate_registration_attempts, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_instances, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, + kilocode_users, + referral_codes, +} from '@kilocode/db/schema'; +import { and, eq, inArray, like, or } from 'drizzle-orm'; + +import { getSeedDb } from './db'; + +type SeedUserFixture = { + id: string; + email: string; + name: string; + imageUrl?: string; + stripeCustomerId?: string; + normalizedEmail?: string; + isAdmin?: boolean; +}; + +type PersonalSubscriptionFixture = { + userId: string; + sandboxId: string; + name?: string | null; + organizationId?: string | null; + plan: 'trial' | 'standard' | 'commit'; + status: KiloClawSubscriptionStatus; + paymentSource?: KiloClawPaymentSource; + currentPeriodStart?: string | null; + currentPeriodEnd?: string | null; + creditRenewalAt?: string | null; + trialStartedAt?: string | null; + trialEndsAt?: string | null; + commitEndsAt?: string | null; + cancelAtPeriodEnd?: boolean; +}; + +function buildOrConditions(conditions: Array): TCondition[] { + return conditions.filter(condition => condition !== undefined); +} + +export function seedLabelForScenario(scenario: string): string { + return `seed:kiloclaw:${scenario}`; +} + +export function seedUserId(scenario: string, role: string): string { + return `seed-kiloclaw-${scenario}-${role}`; +} + +export function seedEmail(scenario: string, role: string): string { + return `${seedUserId(scenario, role)}@example.com`; +} + +export function seedOpaqueReferralIdentifier(scenario: string, slug: string): string { + return `${seedLabelForScenario(scenario)}:share:${slug}`; +} + +export function seedSourcePaymentId(scenario: string, slug: string): string { + return `kiloclaw-subscription:seed-kiloclaw-${scenario}-${slug}`; +} + +export function seedOrderId(scenario: string, slug: string): string { + return `${seedLabelForScenario(scenario)}:order:${slug}`; +} + +export function addDays(iso: string, days: number): string { + const date = new Date(iso); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString(); +} + +export function addMonthsUtc(iso: string, months: number): string { + const date = new Date(iso); + const originalDay = date.getUTCDate(); + + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() + months); + + const lastDayOfTargetMonth = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0) + ).getUTCDate(); + + date.setUTCDate(Math.min(originalDay, lastDayOfTargetMonth)); + return date.toISOString(); +} + +export async function cleanupKiloClawReferralSeedScenario(params: { + scenario: string; + userIds: string[]; +}): Promise { + const db = getSeedDb(); + const scenarioPrefix = `${seedLabelForScenario(params.scenario)}%`; + + const conversionRows = await db + .select({ id: kiloclaw_referral_conversions.id }) + .from(kiloclaw_referral_conversions) + .where( + or( + like(kiloclaw_referral_conversions.source_payment_id, scenarioPrefix), + inArray(kiloclaw_referral_conversions.referee_user_id, params.userIds), + inArray(kiloclaw_referral_conversions.referrer_user_id, params.userIds) + ) + ); + const conversionIds = conversionRows.map(row => row.id); + + const rewardRows = conversionIds.length + ? await db + .select({ id: kiloclaw_referral_rewards.id }) + .from(kiloclaw_referral_rewards) + .where( + or( + inArray(kiloclaw_referral_rewards.conversion_id, conversionIds), + inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.userIds) + ) + ) + : await db + .select({ id: kiloclaw_referral_rewards.id }) + .from(kiloclaw_referral_rewards) + .where(inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.userIds)); + const rewardIds = rewardRows.map(row => row.id); + + const participantRows = await db + .select({ id: impact_advocate_participants.id }) + .from(impact_advocate_participants) + .where(inArray(impact_advocate_participants.user_id, params.userIds)); + const participantIds = participantRows.map(row => row.id); + + const subscriptionRows = await db + .select({ id: kiloclaw_subscriptions.id }) + .from(kiloclaw_subscriptions) + .where(inArray(kiloclaw_subscriptions.user_id, params.userIds)); + const subscriptionIds = subscriptionRows.map(row => row.id); + + const rewardApplicationConditions = buildOrConditions([ + rewardIds.length + ? inArray(kiloclaw_referral_reward_applications.reward_id, rewardIds) + : undefined, + params.userIds.length + ? inArray(kiloclaw_referral_reward_applications.beneficiary_user_id, params.userIds) + : undefined, + ]); + if (rewardApplicationConditions.length > 0) { + await db + .delete(kiloclaw_referral_reward_applications) + .where(or(...rewardApplicationConditions)); + } + + const reportConditions = buildOrConditions([ + conversionIds.length + ? inArray(impact_conversion_reports.conversion_id, conversionIds) + : undefined, + like(impact_conversion_reports.dedupe_key, scenarioPrefix), + ]); + await db.delete(impact_conversion_reports).where(or(...reportConditions)); + + const rewardConditions = buildOrConditions([ + rewardIds.length ? inArray(kiloclaw_referral_rewards.id, rewardIds) : undefined, + params.userIds.length + ? inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.userIds) + : undefined, + ]); + if (rewardConditions.length > 0) { + await db.delete(kiloclaw_referral_rewards).where(or(...rewardConditions)); + } + + if (conversionIds.length > 0) { + await db + .delete(kiloclaw_referral_reward_decisions) + .where(inArray(kiloclaw_referral_reward_decisions.conversion_id, conversionIds)); + } + + await db + .delete(kiloclaw_referrals) + .where( + or( + inArray(kiloclaw_referrals.referee_user_id, params.userIds), + inArray(kiloclaw_referrals.referrer_user_id, params.userIds) + ) + ); + + const conversionConditions = buildOrConditions([ + like(kiloclaw_referral_conversions.source_payment_id, scenarioPrefix), + inArray(kiloclaw_referral_conversions.referee_user_id, params.userIds), + inArray(kiloclaw_referral_conversions.referrer_user_id, params.userIds), + ]); + await db.delete(kiloclaw_referral_conversions).where(or(...conversionConditions)); + + const attemptConditions = buildOrConditions([ + like(impact_advocate_registration_attempts.dedupe_key, scenarioPrefix), + participantIds.length + ? inArray(impact_advocate_registration_attempts.participant_id, participantIds) + : undefined, + ]); + await db.delete(impact_advocate_registration_attempts).where(or(...attemptConditions)); + + await db + .delete(kiloclaw_attribution_touches) + .where( + or( + like(kiloclaw_attribution_touches.dedupe_key, scenarioPrefix), + inArray(kiloclaw_attribution_touches.user_id, params.userIds) + ) + ); + + await db + .delete(credit_transactions) + .where( + or( + inArray(credit_transactions.kilo_user_id, params.userIds), + like(credit_transactions.credit_category, scenarioPrefix), + like( + credit_transactions.credit_category, + `kiloclaw-subscription:seed-kiloclaw-${params.scenario}%` + ) + ) + ); + + if (subscriptionIds.length > 0) { + await db + .delete(kiloclaw_subscription_change_log) + .where(inArray(kiloclaw_subscription_change_log.subscription_id, subscriptionIds)); + } + + await db + .delete(kiloclaw_subscriptions) + .where(inArray(kiloclaw_subscriptions.user_id, params.userIds)); + await db.delete(kiloclaw_instances).where(inArray(kiloclaw_instances.user_id, params.userIds)); + await db + .delete(impact_advocate_participants) + .where(inArray(impact_advocate_participants.user_id, params.userIds)); + await db.delete(referral_codes).where(inArray(referral_codes.kilo_user_id, params.userIds)); + await db.delete(kilocode_users).where(inArray(kilocode_users.id, params.userIds)); +} + +export async function insertSeedUsers(users: SeedUserFixture[]): Promise { + const db = getSeedDb(); + await db.insert(kilocode_users).values( + users.map(user => ({ + id: user.id, + google_user_email: user.email, + google_user_name: user.name, + google_user_image_url: + user.imageUrl ?? `https://example.com/${encodeURIComponent(user.id)}.png`, + stripe_customer_id: + user.stripeCustomerId ?? `cus_${user.id.replaceAll(/[^a-zA-Z0-9]/g, '_')}`, + normalized_email: user.normalizedEmail ?? user.email.toLowerCase(), + is_admin: user.isAdmin ?? false, + })) + ); +} + +export async function insertImpactAdvocateParticipant(params: { + userId: string; + email: string; + opaqueReferralIdentifier: string; + registeredAt?: string; +}): Promise { + const db = getSeedDb(); + await db.insert(referral_codes).values({ + kilo_user_id: params.userId, + code: params.opaqueReferralIdentifier, + }); + await db.insert(impact_advocate_participants).values({ + user_id: params.userId, + advocate_id: params.userId, + advocate_account_id: params.userId, + opaque_referral_identifier: params.opaqueReferralIdentifier, + contact_email: params.email, + registration_state: 'registered', + registered_at: params.registeredAt ?? new Date().toISOString(), + }); +} + +export async function insertPersonalSubscription(fixture: PersonalSubscriptionFixture): Promise<{ + subscription: typeof kiloclaw_subscriptions.$inferSelect; + instance: typeof kiloclaw_instances.$inferSelect; +}> { + const db = getSeedDb(); + + const [instance] = await db + .insert(kiloclaw_instances) + .values({ + user_id: fixture.userId, + sandbox_id: fixture.sandboxId, + provider: 'docker-local', + name: fixture.name ?? null, + organization_id: fixture.organizationId ?? null, + }) + .returning(); + + const [subscription] = await db + .insert(kiloclaw_subscriptions) + .values({ + user_id: fixture.userId, + instance_id: instance.id, + payment_source: fixture.paymentSource, + plan: fixture.plan, + status: fixture.status, + cancel_at_period_end: fixture.cancelAtPeriodEnd ?? false, + current_period_start: fixture.currentPeriodStart ?? null, + current_period_end: fixture.currentPeriodEnd ?? null, + credit_renewal_at: fixture.creditRenewalAt ?? null, + trial_started_at: fixture.trialStartedAt ?? null, + trial_ends_at: fixture.trialEndsAt ?? null, + commit_ends_at: fixture.commitEndsAt ?? null, + }) + .returning(); + + return { subscription, instance }; +} + +export async function insertAppliedRewardChangeLog(params: { + subscription: typeof kiloclaw_subscriptions.$inferSelect; + previousBoundary: string; + newBoundary: string; +}): Promise { + const beforeSubscription = { + ...params.subscription, + current_period_end: params.previousBoundary, + credit_renewal_at: params.previousBoundary, + }; + const afterSubscription = { + ...params.subscription, + current_period_end: params.newBoundary, + credit_renewal_at: params.newBoundary, + }; + + await insertKiloClawSubscriptionChangeLog(getSeedDb(), { + subscriptionId: params.subscription.id, + actor: { + actorType: 'system', + actorId: 'kiloclaw-referrals', + }, + action: 'period_advanced', + reason: 'referral_reward:applied', + before: beforeSubscription, + after: afterSubscription, + }); +} + +export async function assertUserCount(params: { userIds: string[]; expectedCount: number }) { + const db = getSeedDb(); + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(inArray(kilocode_users.id, params.userIds)); + + if (rows.length !== params.expectedCount) { + throw new Error(`Expected ${params.expectedCount} seed users, found ${rows.length}`); + } +} diff --git a/package.json b/package.json index b8cc165841..a3b89c9819 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "format:changed": "git diff --name-only $(git merge-base origin/main HEAD) --diff-filter=ACMR -- '**/*.js' '**/*.jsx' '**/*.ts' '**/*.tsx' '**/*.json' '**/*.css' '**/*.md' | xargs -r oxfmt --no-error-on-unmatched-pattern", "validate": "pnpm run typecheck && pnpm run lint && pnpm run test", "drizzle": "pnpm --filter @kilocode/db exec drizzle-kit", + "drizzle:verify-bootstrap": "bash scripts/verify-drizzle-bootstrap.sh", "test:e2e": "pnpm --filter web run test:e2e", "dependency-cycle-check": "pnpm --filter web run dependency-cycle-check", "worktree:prepare": "bash scripts/worktree-prepare.sh", "test:db": "docker compose -f dev/docker-compose.yml up -d --wait postgres && pnpm drizzle migrate", + "dev:db:reset": "pnpm --filter web db:empty-database", "dev:start": "tsx dev/local/cli.ts up", "dev:stop": "tsx dev/local/cli.ts stop", "dev:status": "tsx dev/local/cli.ts status", @@ -30,7 +32,7 @@ "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts", "dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts" }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "packageManager": "pnpm@10.33.2", "devDependencies": { "@typescript/native-preview": "catalog:", "husky": "^9.1.7", diff --git a/packages/db/src/migrations/0119_sad_katie_power.sql b/packages/db/src/migrations/0119_sad_katie_power.sql new file mode 100644 index 0000000000..c11e2a80b8 --- /dev/null +++ b/packages/db/src/migrations/0119_sad_katie_power.sql @@ -0,0 +1,234 @@ +CREATE TABLE "deleted_user_email_tombstones" ( + "normalized_email_hash" text PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "impact_advocate_participants" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "advocate_id" text NOT NULL, + "advocate_account_id" text NOT NULL, + "opaque_referral_identifier" text, + "contact_email" text, + "locale" text, + "country_code" text, + "registration_state" text DEFAULT 'pending' NOT NULL, + "registered_at" timestamp with time zone, + "last_registration_attempt_at" timestamp with time zone, + "last_error_code" text, + "last_error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_advocate_participants_user_id" UNIQUE("user_id"), + CONSTRAINT "UQ_impact_advocate_participants_opaque_referral_identifier" UNIQUE("opaque_referral_identifier"), + CONSTRAINT "impact_advocate_participants_registration_state_check" CHECK ("impact_advocate_participants"."registration_state" IN ('pending', 'retrying', 'registered', 'failed')) +); +--> statement-breakpoint +CREATE TABLE "impact_advocate_registration_attempts" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "participant_id" uuid NOT NULL, + "dedupe_key" text NOT NULL, + "opaque_cookie_value" text, + "cookie_value_length" integer NOT NULL, + "delivery_state" text DEFAULT 'queued' NOT NULL, + "request_payload" jsonb, + "response_payload" jsonb, + "response_status_code" integer, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "claimed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_advocate_registration_attempts_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "impact_advocate_registration_attempts_delivery_state_check" CHECK ("impact_advocate_registration_attempts"."delivery_state" IN ('queued', 'sending', 'succeeded', 'failed')), + CONSTRAINT "impact_advocate_registration_attempts_cookie_value_length_non_negative_check" CHECK ("impact_advocate_registration_attempts"."cookie_value_length" >= 0), + CONSTRAINT "impact_advocate_registration_attempts_attempt_count_non_negative_check" CHECK ("impact_advocate_registration_attempts"."attempt_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "impact_advocate_reward_redemptions" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "reward_id" uuid NOT NULL, + "dedupe_key" text NOT NULL, + "beneficiary_user_id" text NOT NULL, + "state" text DEFAULT 'queued' NOT NULL, + "impact_reward_id" text, + "request_payload" jsonb, + "lookup_response_payload" jsonb, + "redeem_response_payload" jsonb, + "response_status_code" integer, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "redeemed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_advocate_reward_redemptions_reward_id" UNIQUE("reward_id"), + CONSTRAINT "UQ_impact_advocate_reward_redemptions_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "impact_advocate_reward_redemptions_state_check" CHECK ("impact_advocate_reward_redemptions"."state" IN ('queued', 'retrying', 'redeemed', 'failed')), + CONSTRAINT "impact_advocate_reward_redemptions_attempt_count_non_negative_check" CHECK ("impact_advocate_reward_redemptions"."attempt_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "impact_conversion_reports" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "conversion_id" uuid, + "dedupe_key" text NOT NULL, + "action_tracker_id" integer NOT NULL, + "order_id" text NOT NULL, + "state" text DEFAULT 'queued' NOT NULL, + "request_payload" jsonb, + "response_payload" jsonb, + "response_status_code" integer, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "delivered_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_conversion_reports_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "impact_conversion_reports_state_check" CHECK ("impact_conversion_reports"."state" IN ('queued', 'retrying', 'delivered', 'failed')), + CONSTRAINT "impact_conversion_reports_attempt_count_non_negative_check" CHECK ("impact_conversion_reports"."attempt_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_attribution_touches" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "dedupe_key" text NOT NULL, + "anonymous_id" text, + "user_id" text, + "touch_type" text NOT NULL, + "provider" text NOT NULL, + "opaque_tracking_value" text, + "tracking_value_length" integer NOT NULL, + "is_tracking_value_accepted" boolean DEFAULT true NOT NULL, + "rs_code" text, + "rs_share_medium" text, + "rs_engagement_medium" text, + "im_ref" text, + "landing_path" text, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_term" text, + "utm_content" text, + "touched_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "sale_attributed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_attribution_touches_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "kiloclaw_attribution_touches_touch_type_check" CHECK ("kiloclaw_attribution_touches"."touch_type" IN ('affiliate', 'referral')), + CONSTRAINT "kiloclaw_attribution_touches_provider_check" CHECK ("kiloclaw_attribution_touches"."provider" IN ('impact_performance', 'impact_advocate')), + CONSTRAINT "kiloclaw_attribution_touches_tracking_value_length_non_negative_check" CHECK ("kiloclaw_attribution_touches"."tracking_value_length" >= 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_conversions" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "referee_user_id" text NOT NULL, + "referrer_user_id" text, + "source_touch_id" uuid, + "winning_touch_type" text NOT NULL, + "source_payment_id" text NOT NULL, + "qualified" boolean DEFAULT false NOT NULL, + "disqualification_reason" text, + "converted_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referral_conversions_source_payment_id" UNIQUE("source_payment_id"), + CONSTRAINT "kiloclaw_referral_conversions_winning_touch_type_check" CHECK ("kiloclaw_referral_conversions"."winning_touch_type" IN ('referral', 'affiliate', 'none')) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_reward_applications" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "reward_id" uuid NOT NULL, + "beneficiary_user_id" text NOT NULL, + "subscription_id" uuid, + "previous_renewal_boundary" timestamp with time zone NOT NULL, + "new_renewal_boundary" timestamp with time zone NOT NULL, + "local_operation_id" text, + "stripe_operation_id" text, + "stripe_idempotency_key" text, + "applied_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_reward_decisions" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "conversion_id" uuid NOT NULL, + "beneficiary_user_id" text NOT NULL, + "beneficiary_role" text NOT NULL, + "outcome" text NOT NULL, + "reason" text, + "months_granted" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referral_reward_decisions_conversion_role" UNIQUE("conversion_id","beneficiary_role"), + CONSTRAINT "kiloclaw_referral_reward_decisions_beneficiary_role_check" CHECK ("kiloclaw_referral_reward_decisions"."beneficiary_role" IN ('referrer', 'referee')), + CONSTRAINT "kiloclaw_referral_reward_decisions_outcome_check" CHECK ("kiloclaw_referral_reward_decisions"."outcome" IN ('granted', 'cap_limited', 'disqualified')), + CONSTRAINT "kiloclaw_referral_reward_decisions_months_granted_non_negative_check" CHECK ("kiloclaw_referral_reward_decisions"."months_granted" >= 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_rewards" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "conversion_id" uuid NOT NULL, + "decision_id" uuid NOT NULL, + "beneficiary_user_id" text NOT NULL, + "beneficiary_role" text NOT NULL, + "months_granted" integer DEFAULT 1 NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "applies_to_subscription_id" uuid, + "earned_at" timestamp with time zone NOT NULL, + "applied_at" timestamp with time zone, + "reversed_at" timestamp with time zone, + "expires_at" timestamp with time zone, + "review_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referral_rewards_conversion_role" UNIQUE("conversion_id","beneficiary_role"), + CONSTRAINT "UQ_kiloclaw_referral_rewards_decision_id" UNIQUE("decision_id"), + CONSTRAINT "kiloclaw_referral_rewards_beneficiary_role_check" CHECK ("kiloclaw_referral_rewards"."beneficiary_role" IN ('referrer', 'referee')), + CONSTRAINT "kiloclaw_referral_rewards_status_check" CHECK ("kiloclaw_referral_rewards"."status" IN ('pending', 'earned', 'applied', 'reversed', 'expired', 'canceled', 'review_required')), + CONSTRAINT "kiloclaw_referral_rewards_months_granted_positive_check" CHECK ("kiloclaw_referral_rewards"."months_granted" > 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referrals" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "referee_user_id" text NOT NULL, + "referrer_user_id" text, + "source_touch_id" uuid, + "impact_referral_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referrals_referee_user_id" UNIQUE("referee_user_id") +); +--> statement-breakpoint +ALTER TABLE "impact_advocate_participants" ADD CONSTRAINT "impact_advocate_participants_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_advocate_registration_attempts" ADD CONSTRAINT "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."impact_advocate_participants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_advocate_reward_redemptions" ADD CONSTRAINT "impact_advocate_reward_redemptions_reward_id_kiloclaw_referral_rewards_id_fk" FOREIGN KEY ("reward_id") REFERENCES "public"."kiloclaw_referral_rewards"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_advocate_reward_redemptions" ADD CONSTRAINT "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_conversion_reports" ADD CONSTRAINT "impact_conversion_reports_conversion_id_kiloclaw_referral_conversions_id_fk" FOREIGN KEY ("conversion_id") REFERENCES "public"."kiloclaw_referral_conversions"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_attribution_touches" ADD CONSTRAINT "kiloclaw_attribution_touches_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_conversions" ADD CONSTRAINT "kiloclaw_referral_conversions_referee_user_id_kilocode_users_id_fk" FOREIGN KEY ("referee_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_conversions" ADD CONSTRAINT "kiloclaw_referral_conversions_referrer_user_id_kilocode_users_id_fk" FOREIGN KEY ("referrer_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_conversions" ADD CONSTRAINT "kiloclaw_referral_conversions_source_touch_id_kiloclaw_attribution_touches_id_fk" FOREIGN KEY ("source_touch_id") REFERENCES "public"."kiloclaw_attribution_touches"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_applications" ADD CONSTRAINT "kiloclaw_referral_reward_applications_reward_id_kiloclaw_referral_rewards_id_fk" FOREIGN KEY ("reward_id") REFERENCES "public"."kiloclaw_referral_rewards"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_applications" ADD CONSTRAINT "kiloclaw_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_decisions" ADD CONSTRAINT "kiloclaw_referral_reward_decisions_conversion_id_kiloclaw_referral_conversions_id_fk" FOREIGN KEY ("conversion_id") REFERENCES "public"."kiloclaw_referral_conversions"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_decisions" ADD CONSTRAINT "kiloclaw_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_rewards" ADD CONSTRAINT "kiloclaw_referral_rewards_conversion_id_kiloclaw_referral_conversions_id_fk" FOREIGN KEY ("conversion_id") REFERENCES "public"."kiloclaw_referral_conversions"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_rewards" ADD CONSTRAINT "kiloclaw_referral_rewards_decision_id_kiloclaw_referral_reward_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."kiloclaw_referral_reward_decisions"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_rewards" ADD CONSTRAINT "kiloclaw_referral_rewards_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referrals" ADD CONSTRAINT "kiloclaw_referrals_referee_user_id_kilocode_users_id_fk" FOREIGN KEY ("referee_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referrals" ADD CONSTRAINT "kiloclaw_referrals_referrer_user_id_kilocode_users_id_fk" FOREIGN KEY ("referrer_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referrals" ADD CONSTRAINT "kiloclaw_referrals_source_touch_id_kiloclaw_attribution_touches_id_fk" FOREIGN KEY ("source_touch_id") REFERENCES "public"."kiloclaw_attribution_touches"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_participants_registration_state" ON "impact_advocate_participants" USING btree ("registration_state");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_registration_attempts_participant_id" ON "impact_advocate_registration_attempts" USING btree ("participant_id");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_registration_attempts_delivery_state" ON "impact_advocate_registration_attempts" USING btree ("delivery_state");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_reward_redemptions_beneficiary_user_id" ON "impact_advocate_reward_redemptions" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_reward_redemptions_state" ON "impact_advocate_reward_redemptions" USING btree ("state");--> statement-breakpoint +CREATE INDEX "IDX_impact_conversion_reports_conversion_id" ON "impact_conversion_reports" USING btree ("conversion_id");--> statement-breakpoint +CREATE INDEX "IDX_impact_conversion_reports_state" ON "impact_conversion_reports" USING btree ("state");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_user_id" ON "kiloclaw_attribution_touches" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_anonymous_id" ON "kiloclaw_attribution_touches" USING btree ("anonymous_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_expires_at" ON "kiloclaw_attribution_touches" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_sale_attributed_at" ON "kiloclaw_attribution_touches" USING btree ("sale_attributed_at");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_conversions_referee_user_id" ON "kiloclaw_referral_conversions" USING btree ("referee_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_conversions_referrer_user_id" ON "kiloclaw_referral_conversions" USING btree ("referrer_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_reward_applications_reward_id" ON "kiloclaw_referral_reward_applications" USING btree ("reward_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_reward_applications_beneficiary_user_id" ON "kiloclaw_referral_reward_applications" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id" ON "kiloclaw_referral_reward_decisions" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_rewards_beneficiary_user_id" ON "kiloclaw_referral_rewards" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_rewards_status" ON "kiloclaw_referral_rewards" USING btree ("status");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referrals_referrer_user_id" ON "kiloclaw_referrals" USING btree ("referrer_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referrals_source_touch_id" ON "kiloclaw_referrals" USING btree ("source_touch_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0119_snapshot.json b/packages/db/src/migrations/meta/0119_snapshot.json new file mode 100644 index 0000000000..6cd5fe1215 --- /dev/null +++ b/packages/db/src/migrations/meta/0119_snapshot.json @@ -0,0 +1,20895 @@ +{ + "id": "baa49fb9-8dcd-47b5-a0b6-e05aae7250b2", + "prevId": "6099d909-8168-469c-a73f-f7cdbf127379", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_configs": { + "name": "agent_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "runtime_state": { + "name": "runtime_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_configs_org_id": { + "name": "IDX_agent_configs_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_owned_by_user_id": { + "name": "IDX_agent_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_agent_type": { + "name": "IDX_agent_configs_agent_type", + "columns": [ + { + "expression": "agent_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_platform": { + "name": "IDX_agent_configs_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_configs_owned_by_organization_id_organizations_id_fk": { + "name": "agent_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_configs_org_agent_platform": { + "name": "UQ_agent_configs_org_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_organization_id", + "agent_type", + "platform" + ] + }, + "UQ_agent_configs_user_agent_platform": { + "name": "UQ_agent_configs_user_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_user_id", + "agent_type", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": { + "agent_configs_owner_check": { + "name": "agent_configs_owner_check", + "value": "(\n (\"agent_configs\".\"owned_by_user_id\" IS NOT NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_configs\".\"owned_by_user_id\" IS NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "agent_configs_agent_type_check": { + "name": "agent_configs_agent_type_check", + "value": "\"agent_configs\".\"agent_type\" IN ('code_review', 'auto_triage', 'auto_fix', 'security_scan')" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_agents": { + "name": "agent_environment_profile_agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_agents_profile_id": { + "name": "IDX_agent_env_profile_agents_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_agents", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_agents_profile_slug": { + "name": "UQ_agent_env_profile_agents_profile_slug", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_commands": { + "name": "agent_environment_profile_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_commands_profile_id": { + "name": "IDX_agent_env_profile_commands_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_commands", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_commands_profile_sequence": { + "name": "UQ_agent_env_profile_commands_profile_sequence", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "sequence" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_mcp_servers": { + "name": "agent_environment_profile_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_mcp_servers_profile_id": { + "name": "IDX_agent_env_profile_mcp_servers_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_mcp_servers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_mcp_servers_profile_name": { + "name": "UQ_agent_env_profile_mcp_servers_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_repo_bindings": { + "name": "agent_environment_profile_repo_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profile_repo_bindings_user": { + "name": "UQ_agent_env_profile_repo_bindings_user", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profile_repo_bindings_org": { + "name": "UQ_agent_env_profile_repo_bindings_org", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profile_repo_bindings_owner_check": { + "name": "agent_env_profile_repo_bindings_owner_check", + "value": "(\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_skills": { + "name": "agent_environment_profile_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_markdown": { + "name": "raw_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_skills_profile_id": { + "name": "IDX_agent_env_profile_skills_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_skills", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_skills_profile_name": { + "name": "UQ_agent_env_profile_skills_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_vars": { + "name": "agent_environment_profile_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_vars_profile_id": { + "name": "IDX_agent_env_profile_vars_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_vars", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_vars_profile_key": { + "name": "UQ_agent_env_profile_vars_profile_key", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profiles": { + "name": "agent_environment_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profiles_org_name": { + "name": "UQ_agent_env_profiles_org_name", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_name": { + "name": "UQ_agent_env_profiles_user_name", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_org_default": { + "name": "UQ_agent_env_profiles_org_default", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_default": { + "name": "UQ_agent_env_profiles_user_default", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_org_id": { + "name": "IDX_agent_env_profiles_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_user_id": { + "name": "IDX_agent_env_profiles_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_created_by_user_id": { + "name": "IDX_agent_env_profiles_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profiles_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profiles_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profiles_owner_check": { + "name": "agent_env_profiles_owner_check", + "value": "(\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.api_kind": { + "name": "api_kind", + "schema": "", + "columns": { + "api_kind_id": { + "name": "api_kind_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_api_kind": { + "name": "UQ_api_kind", + "columns": [ + { + "expression": "api_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_request_log": { + "name": "api_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_request_log_created_at": { + "name": "idx_api_request_log_created_at", + "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_builder_feedback": { + "name": "app_builder_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_status": { + "name": "preview_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_feedback_created_at": { + "name": "IDX_app_builder_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_kilo_user_id": { + "name": "IDX_app_builder_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_project_id": { + "name": "IDX_app_builder_feedback_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "app_builder_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "app_builder_feedback_project_id_app_builder_projects_id_fk": { + "name": "app_builder_feedback_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_project_sessions": { + "name": "app_builder_project_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v2'" + } + }, + "indexes": { + "IDX_app_builder_project_sessions_project_id": { + "name": "IDX_app_builder_project_sessions_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_project_sessions_project_id_app_builder_projects_id_fk": { + "name": "app_builder_project_sessions_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_project_sessions", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_app_builder_project_sessions_cloud_agent_session_id": { + "name": "UQ_app_builder_project_sessions_cloud_agent_session_id", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_projects": { + "name": "app_builder_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "git_repo_full_name": { + "name": "git_repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_platform_integration_id": { + "name": "git_platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_projects_created_by_user_id": { + "name": "IDX_app_builder_projects_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_user_id": { + "name": "IDX_app_builder_projects_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_organization_id": { + "name": "IDX_app_builder_projects_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_created_at": { + "name": "IDX_app_builder_projects_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_last_message_at": { + "name": "IDX_app_builder_projects_last_message_at", + "columns": [ + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_projects_owned_by_user_id_kilocode_users_id_fk": { + "name": "app_builder_projects_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_owned_by_organization_id_organizations_id_fk": { + "name": "app_builder_projects_owned_by_organization_id_organizations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_deployment_id_deployments_id_fk": { + "name": "app_builder_projects_deployment_id_deployments_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk": { + "name": "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "platform_integrations", + "columnsFrom": [ + "git_platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_builder_projects_owner_check": { + "name": "app_builder_projects_owner_check", + "value": "(\n (\"app_builder_projects\".\"owned_by_user_id\" IS NOT NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NULL) OR\n (\"app_builder_projects\".\"owned_by_user_id\" IS NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.app_min_versions": { + "name": "app_min_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ios_min_version": { + "name": "ios_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "android_min_version": { + "name": "android_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_reported_messages": { + "name": "app_reported_messages", + "schema": "", + "columns": { + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_reported_messages_cli_session_id_cli_sessions_session_id_fk": { + "name": "app_reported_messages_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "app_reported_messages", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_fix_tickets": { + "name": "auto_fix_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "triage_ticket_id": { + "name": "triage_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'label'" + }, + "review_comment_id": { + "name": "review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "review_comment_body": { + "name": "review_comment_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "diff_hunk": { + "name": "diff_hunk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_head_ref": { + "name": "pr_head_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_branch": { + "name": "pr_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_fix_tickets_repo_issue": { + "name": "UQ_auto_fix_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"trigger_source\" = 'label'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_fix_tickets_repo_review_comment": { + "name": "UQ_auto_fix_tickets_repo_review_comment", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"review_comment_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_org": { + "name": "IDX_auto_fix_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_user": { + "name": "IDX_auto_fix_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_status": { + "name": "IDX_auto_fix_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_created_at": { + "name": "IDX_auto_fix_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_triage_ticket_id": { + "name": "IDX_auto_fix_tickets_triage_ticket_id", + "columns": [ + { + "expression": "triage_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_session_id": { + "name": "IDX_auto_fix_tickets_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_fix_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_fix_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "triage_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk": { + "name": "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_fix_tickets_owner_check": { + "name": "auto_fix_tickets_owner_check", + "value": "(\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_fix_tickets_status_check": { + "name": "auto_fix_tickets_status_check", + "value": "\"auto_fix_tickets\".\"status\" IN ('pending', 'running', 'completed', 'failed', 'cancelled')" + }, + "auto_fix_tickets_classification_check": { + "name": "auto_fix_tickets_classification_check", + "value": "\"auto_fix_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'unclear')" + }, + "auto_fix_tickets_confidence_check": { + "name": "auto_fix_tickets_confidence_check", + "value": "\"auto_fix_tickets\".\"confidence\" >= 0 AND \"auto_fix_tickets\".\"confidence\" <= 1" + }, + "auto_fix_tickets_trigger_source_check": { + "name": "auto_fix_tickets_trigger_source_check", + "value": "\"auto_fix_tickets\".\"trigger_source\" IN ('label', 'review_comment')" + } + }, + "isRLSEnabled": false + }, + "public.auto_model": { + "name": "auto_model", + "schema": "", + "columns": { + "auto_model_id": { + "name": "auto_model_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_auto_model": { + "name": "UQ_auto_model", + "columns": [ + { + "expression": "auto_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_top_up_configs": { + "name": "auto_top_up_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_method_id": { + "name": "stripe_payment_method_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "last_auto_top_up_at": { + "name": "last_auto_top_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_top_up_configs_owned_by_user_id": { + "name": "UQ_auto_top_up_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_top_up_configs_owned_by_organization_id": { + "name": "UQ_auto_top_up_configs_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "auto_top_up_configs_owned_by_organization_id_organizations_id_fk": { + "name": "auto_top_up_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_top_up_configs_exactly_one_owner": { + "name": "auto_top_up_configs_exactly_one_owner", + "value": "(\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NULL) OR (\"auto_top_up_configs\".\"owned_by_user_id\" IS NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.auto_triage_tickets": { + "name": "auto_triage_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_duplicate": { + "name": "is_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duplicate_of_ticket_id": { + "name": "duplicate_of_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "similarity_score": { + "name": "similarity_score", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "qdrant_point_id": { + "name": "qdrant_point_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "should_auto_fix": { + "name": "should_auto_fix", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "action_taken": { + "name": "action_taken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_metadata": { + "name": "action_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_triage_tickets_repo_issue": { + "name": "UQ_auto_triage_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_org": { + "name": "IDX_auto_triage_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_user": { + "name": "IDX_auto_triage_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_status": { + "name": "IDX_auto_triage_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_created_at": { + "name": "IDX_auto_triage_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_qdrant_point_id": { + "name": "IDX_auto_triage_tickets_qdrant_point_id", + "columns": [ + { + "expression": "qdrant_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owner_status_created": { + "name": "IDX_auto_triage_tickets_owner_status_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_user_status_created": { + "name": "IDX_auto_triage_tickets_user_status_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_repo_classification": { + "name": "IDX_auto_triage_tickets_repo_classification", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "classification", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_triage_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_triage_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "duplicate_of_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_triage_tickets_owner_check": { + "name": "auto_triage_tickets_owner_check", + "value": "(\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_triage_tickets_issue_type_check": { + "name": "auto_triage_tickets_issue_type_check", + "value": "\"auto_triage_tickets\".\"issue_type\" IN ('issue', 'pull_request')" + }, + "auto_triage_tickets_classification_check": { + "name": "auto_triage_tickets_classification_check", + "value": "\"auto_triage_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'duplicate', 'unclear')" + }, + "auto_triage_tickets_confidence_check": { + "name": "auto_triage_tickets_confidence_check", + "value": "\"auto_triage_tickets\".\"confidence\" >= 0 AND \"auto_triage_tickets\".\"confidence\" <= 1" + }, + "auto_triage_tickets_similarity_score_check": { + "name": "auto_triage_tickets_similarity_score_check", + "value": "\"auto_triage_tickets\".\"similarity_score\" >= 0 AND \"auto_triage_tickets\".\"similarity_score\" <= 1" + }, + "auto_triage_tickets_status_check": { + "name": "auto_triage_tickets_status_check", + "value": "\"auto_triage_tickets\".\"status\" IN ('pending', 'analyzing', 'actioned', 'failed', 'skipped')" + }, + "auto_triage_tickets_action_taken_check": { + "name": "auto_triage_tickets_action_taken_check", + "value": "\"auto_triage_tickets\".\"action_taken\" IN ('pr_created', 'comment_posted', 'closed_duplicate', 'needs_clarification')" + } + }, + "isRLSEnabled": false + }, + "public.bot_request_cloud_agent_sessions": { + "name": "bot_request_cloud_agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "bot_request_id": { + "name": "bot_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "spawn_group_id": { + "name": "spawn_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlab_project": { + "name": "gitlab_project", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_step": { + "name": "callback_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message": { + "name": "final_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message_fetched_at": { + "name": "final_message_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "final_message_error": { + "name": "final_message_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "continuation_started_at": { + "name": "continuation_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_bot_request_cas_cloud_agent_session_id": { + "name": "UQ_bot_request_cas_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id": { + "name": "IDX_bot_request_cas_bot_request_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id_status": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id_status", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk": { + "name": "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk", + "tableFrom": "bot_request_cloud_agent_sessions", + "tableTo": "bot_requests", + "columnsFrom": [ + "bot_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_requests": { + "name": "bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_thread_id": { + "name": "platform_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_bot_requests_created_at": { + "name": "IDX_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_created_by": { + "name": "IDX_bot_requests_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_organization_id": { + "name": "IDX_bot_requests_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_platform_integration_id": { + "name": "IDX_bot_requests_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_status": { + "name": "IDX_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_requests_created_by_kilocode_users_id_fk": { + "name": "bot_requests_created_by_kilocode_users_id_fk", + "tableFrom": "bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_organization_id_organizations_id_fk": { + "name": "bot_requests_organization_id_organizations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.byok_api_keys": { + "name": "byok_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_byok_api_keys_organization_id": { + "name": "IDX_byok_api_keys_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_kilo_user_id": { + "name": "IDX_byok_api_keys_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_provider_id": { + "name": "IDX_byok_api_keys_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "byok_api_keys_organization_id_organizations_id_fk": { + "name": "byok_api_keys_organization_id_organizations_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "byok_api_keys_kilo_user_id_kilocode_users_id_fk": { + "name": "byok_api_keys_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_byok_api_keys_org_provider": { + "name": "UQ_byok_api_keys_org_provider", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider_id" + ] + }, + "UQ_byok_api_keys_user_provider": { + "name": "UQ_byok_api_keys_user_provider", + "nullsNotDistinct": false, + "columns": [ + "kilo_user_id", + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "byok_api_keys_owner_check": { + "name": "byok_api_keys_owner_check", + "value": "(\n (\"byok_api_keys\".\"kilo_user_id\" IS NOT NULL AND \"byok_api_keys\".\"organization_id\" IS NULL) OR\n (\"byok_api_keys\".\"kilo_user_id\" IS NULL AND \"byok_api_keys\".\"organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cli_sessions": { + "name": "cli_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_mode": { + "name": "last_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_model": { + "name": "last_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cli_sessions_kilo_user_id": { + "name": "IDX_cli_sessions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_created_at": { + "name": "IDX_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_updated_at": { + "name": "IDX_cli_sessions_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_organization_id": { + "name": "IDX_cli_sessions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_user_updated": { + "name": "IDX_cli_sessions_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_forked_from_cli_sessions_session_id_fk": { + "name": "cli_sessions_forked_from_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_parent_session_id_cli_sessions_session_id_fk": { + "name": "cli_sessions_parent_session_id_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_organization_id_organizations_id_fk": { + "name": "cli_sessions_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cli_sessions_cloud_agent_session_id_unique": { + "name": "cli_sessions_cloud_agent_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_sessions_v2": { + "name": "cli_sessions_v2", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_updated_at": { + "name": "status_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cli_sessions_v2_parent_session_id_kilo_user_id": { + "name": "IDX_cli_sessions_v2_parent_session_id_kilo_user_id", + "columns": [ + { + "expression": "parent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_public_id": { + "name": "UQ_cli_sessions_v2_public_id", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"public_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_cloud_agent_session_id": { + "name": "UQ_cli_sessions_v2_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"cloud_agent_session_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_organization_id": { + "name": "IDX_cli_sessions_v2_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_kilo_user_id": { + "name": "IDX_cli_sessions_v2_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_created_at": { + "name": "IDX_cli_sessions_v2_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_user_updated": { + "name": "IDX_cli_sessions_v2_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_v2_organization_id_organizations_id_fk": { + "name": "cli_sessions_v2_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_v2_parent_session_id_kilo_user_id_fk": { + "name": "cli_sessions_v2_parent_session_id_kilo_user_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "cli_sessions_v2", + "columnsFrom": [ + "parent_session_id", + "kilo_user_id" + ], + "columnsTo": [ + "session_id", + "kilo_user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cli_sessions_v2_session_id_kilo_user_id_pk": { + "name": "cli_sessions_v2_session_id_kilo_user_id_pk", + "columns": [ + "session_id", + "kilo_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_code_reviews": { + "name": "cloud_agent_code_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author": { + "name": "pr_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author_github_id": { + "name": "pr_author_github_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_ref": { + "name": "head_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "platform_project_id": { + "name": "platform_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_reason": { + "name": "terminal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'v1'" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens_in": { + "name": "total_tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens_out": { + "name": "total_tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cost_musd": { + "name": "total_cost_musd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_code_reviews_repo_pr_sha": { + "name": "UQ_cloud_agent_code_reviews_repo_pr_sha", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_org_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_user_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_session_id": { + "name": "idx_cloud_agent_code_reviews_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_cli_session_id": { + "name": "idx_cloud_agent_code_reviews_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_status": { + "name": "idx_cloud_agent_code_reviews_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_repo": { + "name": "idx_cloud_agent_code_reviews_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_number": { + "name": "idx_cloud_agent_code_reviews_pr_number", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_created_at": { + "name": "idx_cloud_agent_code_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_author_github_id": { + "name": "idx_cloud_agent_code_reviews_pr_author_github_id", + "columns": [ + { + "expression": "pr_author_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk": { + "name": "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_code_reviews_owner_check": { + "name": "cloud_agent_code_reviews_owner_check", + "value": "(\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NOT NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NULL) OR\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_feedback": { + "name": "cloud_agent_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cloud_agent_feedback_created_at": { + "name": "IDX_cloud_agent_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_kilo_user_id": { + "name": "IDX_cloud_agent_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_cloud_agent_session_id": { + "name": "IDX_cloud_agent_feedback_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "cloud_agent_feedback_organization_id_organizations_id_fk": { + "name": "cloud_agent_feedback_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_webhook_triggers": { + "name": "cloud_agent_webhook_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cloud_agent'" + }, + "kiloclaw_instance_id": { + "name": "kiloclaw_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "activation_mode": { + "name": "activation_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'webhook'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_webhook_triggers_user_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_user_trigger", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_webhook_triggers_org_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_org_trigger", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_user": { + "name": "IDX_cloud_agent_webhook_triggers_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_org": { + "name": "IDX_cloud_agent_webhook_triggers_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_active": { + "name": "IDX_cloud_agent_webhook_triggers_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_profile": { + "name": "IDX_cloud_agent_webhook_triggers_profile", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_organization_id_organizations_id_fk": { + "name": "cloud_agent_webhook_triggers_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk": { + "name": "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "kiloclaw_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk": { + "name": "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_cloud_agent_webhook_triggers_owner": { + "name": "CHK_cloud_agent_webhook_triggers_owner", + "value": "(\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NULL) OR\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_cloud_agent_fields": { + "name": "CHK_cloud_agent_webhook_triggers_cloud_agent_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'cloud_agent' OR\n (\"cloud_agent_webhook_triggers\".\"github_repo\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"profile_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_kiloclaw_fields": { + "name": "CHK_cloud_agent_webhook_triggers_kiloclaw_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'kiloclaw_chat' OR\n \"cloud_agent_webhook_triggers\".\"kiloclaw_instance_id\" IS NOT NULL\n )" + }, + "CHK_cloud_agent_webhook_triggers_scheduled_fields": { + "name": "CHK_cloud_agent_webhook_triggers_scheduled_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"activation_mode\" != 'scheduled' OR\n \"cloud_agent_webhook_triggers\".\"cron_expression\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_indexing_manifest": { + "name": "code_indexing_manifest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_lines": { + "name": "total_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_ai_lines": { + "name": "total_ai_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_manifest_organization_id": { + "name": "IDX_code_indexing_manifest_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_kilo_user_id": { + "name": "IDX_code_indexing_manifest_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_project_id": { + "name": "IDX_code_indexing_manifest_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_file_hash": { + "name": "IDX_code_indexing_manifest_file_hash", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_git_branch": { + "name": "IDX_code_indexing_manifest_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_created_at": { + "name": "IDX_code_indexing_manifest_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_manifest", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_indexing_manifest_org_user_project_hash_branch": { + "name": "UQ_code_indexing_manifest_org_user_project_hash_branch", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "kilo_user_id", + "project_id", + "file_path", + "git_branch" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.code_indexing_search": { + "name": "code_indexing_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_search_organization_id": { + "name": "IDX_code_indexing_search_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_kilo_user_id": { + "name": "IDX_code_indexing_search_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_project_id": { + "name": "IDX_code_indexing_search_project_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_created_at": { + "name": "IDX_code_indexing_search_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_search_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_search_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_search", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_contributors": { + "name": "contributor_champion_contributors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_profile_url": { + "name": "github_profile_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "first_contribution_at": { + "name": "first_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contribution_at": { + "name": "last_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "all_time_contributions": { + "name": "all_time_contributions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "manual_email": { + "name": "manual_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_contributors_last_contribution_at": { + "name": "IDX_contributor_champion_contributors_last_contribution_at", + "columns": [ + { + "expression": "last_contribution_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_contributors_manual_email": { + "name": "IDX_contributor_champion_contributors_manual_email", + "columns": [ + { + "expression": "manual_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_contributors_github_login": { + "name": "UQ_contributor_champion_contributors_github_login", + "nullsNotDistinct": false, + "columns": [ + "github_login" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_events": { + "name": "contributor_champion_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_number": { + "name": "github_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "github_pr_url": { + "name": "github_pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_title": { + "name": "github_pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_login": { + "name": "github_author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_email": { + "name": "github_author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_events_contributor_id": { + "name": "IDX_contributor_champion_events_contributor_id", + "columns": [ + { + "expression": "contributor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_merged_at": { + "name": "IDX_contributor_champion_events_merged_at", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_author_email": { + "name": "IDX_contributor_champion_events_author_email", + "columns": [ + { + "expression": "github_author_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_events", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_events_repo_pr": { + "name": "UQ_contributor_champion_events_repo_pr", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "github_pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_memberships": { + "name": "contributor_champion_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_tier": { + "name": "selected_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_tier": { + "name": "enrolled_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_amount_microdollars": { + "name": "credit_amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credits_last_granted_at": { + "name": "credits_last_granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "linked_kilo_user_id": { + "name": "linked_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_memberships_credits_due": { + "name": "IDX_contributor_champion_memberships_credits_due", + "columns": [ + { + "expression": "credits_last_granted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NOT NULL AND \"contributor_champion_memberships\".\"credit_amount_microdollars\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_memberships_linked_kilo_user_id": { + "name": "IDX_contributor_champion_memberships_linked_kilo_user_id", + "columns": [ + { + "expression": "linked_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk": { + "name": "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "kilocode_users", + "columnsFrom": [ + "linked_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_memberships_contributor_id": { + "name": "UQ_contributor_champion_memberships_contributor_id", + "nullsNotDistinct": false, + "columns": [ + "contributor_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "contributor_champion_memberships_selected_tier_check": { + "name": "contributor_champion_memberships_selected_tier_check", + "value": "\"contributor_champion_memberships\".\"selected_tier\" IS NULL OR \"contributor_champion_memberships\".\"selected_tier\" IN ('contributor', 'ambassador', 'champion')" + }, + "contributor_champion_memberships_enrolled_tier_check": { + "name": "contributor_champion_memberships_enrolled_tier_check", + "value": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NULL OR \"contributor_champion_memberships\".\"enrolled_tier\" IN ('contributor', 'ambassador', 'champion')" + } + }, + "isRLSEnabled": false + }, + "public.contributor_champion_sync_state": { + "name": "contributor_champion_sync_state", + "schema": "", + "columns": { + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_merged_at": { + "name": "last_merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_campaigns": { + "name": "credit_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_expiry_hours": { + "name": "credit_expiry_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_ends_at": { + "name": "campaign_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_redemptions_allowed": { + "name": "total_redemptions_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_credit_campaigns_slug": { + "name": "UQ_credit_campaigns_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_credit_campaigns_credit_category": { + "name": "UQ_credit_campaigns_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credit_campaigns_slug_format_check": { + "name": "credit_campaigns_slug_format_check", + "value": "\"credit_campaigns\".\"slug\" ~ '^[a-z0-9-]{5,40}$'" + }, + "credit_campaigns_amount_positive_check": { + "name": "credit_campaigns_amount_positive_check", + "value": "\"credit_campaigns\".\"amount_microdollars\" > 0" + }, + "credit_campaigns_credit_expiry_hours_positive_check": { + "name": "credit_campaigns_credit_expiry_hours_positive_check", + "value": "\"credit_campaigns\".\"credit_expiry_hours\" IS NULL OR \"credit_campaigns\".\"credit_expiry_hours\" > 0" + }, + "credit_campaigns_total_redemptions_allowed_positive_check": { + "name": "credit_campaigns_total_redemptions_allowed_positive_check", + "value": "\"credit_campaigns\".\"total_redemptions_allowed\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.credit_transactions": { + "name": "credit_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expiration_baseline_microdollars_used": { + "name": "expiration_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "original_baseline_microdollars_used": { + "name": "original_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_transaction_id": { + "name": "original_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_id": { + "name": "stripe_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coinbase_credit_block_id": { + "name": "coinbase_credit_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "check_category_uniqueness": { + "name": "check_category_uniqueness", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_credit_transactions_created_at": { + "name": "IDX_credit_transactions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_is_free": { + "name": "IDX_credit_transactions_is_free", + "columns": [ + { + "expression": "is_free", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_kilo_user_id": { + "name": "IDX_credit_transactions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_credit_category": { + "name": "IDX_credit_transactions_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_stripe_payment_id": { + "name": "IDX_credit_transactions_stripe_payment_id", + "columns": [ + { + "expression": "stripe_payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_original_transaction_id": { + "name": "IDX_credit_transactions_original_transaction_id", + "columns": [ + { + "expression": "original_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_coinbase_credit_block_id": { + "name": "IDX_credit_transactions_coinbase_credit_block_id", + "columns": [ + { + "expression": "coinbase_credit_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_organization_id": { + "name": "IDX_credit_transactions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_unique_category": { + "name": "IDX_credit_transactions_unique_category", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_transactions\".\"check_category_uniqueness\" = TRUE", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_llm2": { + "name": "custom_llm2", + "schema": "", + "columns": { + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deleted_user_email_tombstones": { + "name": "deleted_user_email_tombstones", + "schema": "", + "columns": { + "normalized_email_hash": { + "name": "normalized_email_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_builds": { + "name": "deployment_builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_builds_deployment_id": { + "name": "idx_deployment_builds_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_builds_status": { + "name": "idx_deployment_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_builds_deployment_id_deployments_id_fk": { + "name": "deployment_builds_deployment_id_deployments_id_fk", + "tableFrom": "deployment_builds", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_env_vars": { + "name": "deployment_env_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_env_vars_deployment_id": { + "name": "idx_deployment_env_vars_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_env_vars_deployment_id_deployments_id_fk": { + "name": "deployment_env_vars_deployment_id_deployments_id_fk", + "tableFrom": "deployment_env_vars", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployment_env_vars_deployment_key": { + "name": "UQ_deployment_env_vars_deployment_key", + "nullsNotDistinct": false, + "columns": [ + "deployment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_events": { + "name": "deployment_events", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'log'" + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_deployment_events_build_id": { + "name": "idx_deployment_events_build_id", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_timestamp": { + "name": "idx_deployment_events_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_type": { + "name": "idx_deployment_events_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_events_build_id_deployment_builds_id_fk": { + "name": "deployment_events_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_events", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_events_build_id_event_id_pk": { + "name": "deployment_events_build_id_event_id_pk", + "columns": [ + "build_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_threat_detections": { + "name": "deployment_threat_detections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "threat_type": { + "name": "threat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_threat_detections_deployment_id": { + "name": "idx_deployment_threat_detections_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_threat_detections_created_at": { + "name": "idx_deployment_threat_detections_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_threat_detections_deployment_id_deployments_id_fk": { + "name": "deployment_threat_detections_deployment_id_deployments_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_threat_detections_build_id_deployment_builds_id_fk": { + "name": "deployment_threat_detections_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deployment_slug": { + "name": "deployment_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_worker_name": { + "name": "internal_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_source": { + "name": "repository_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_url": { + "name": "deployment_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "git_auth_token": { + "name": "git_auth_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_deployed_at": { + "name": "last_deployed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_build_id": { + "name": "last_build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threat_status": { + "name": "threat_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_owned_by_user_id": { + "name": "idx_deployments_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_owned_by_organization_id": { + "name": "idx_deployments_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_platform_integration_id": { + "name": "idx_deployments_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_repository_source_branch": { + "name": "idx_deployments_repository_source_branch", + "columns": [ + { + "expression": "repository_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_threat_status_pending": { + "name": "idx_deployments_threat_status_pending", + "columns": [ + { + "expression": "threat_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"deployments\".\"threat_status\" = 'pending_scan'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_owned_by_user_id_kilocode_users_id_fk": { + "name": "deployments_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "deployments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deployments_owned_by_organization_id_organizations_id_fk": { + "name": "deployments_owned_by_organization_id_organizations_id_fk", + "tableFrom": "deployments", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployments_deployment_slug": { + "name": "UQ_deployments_deployment_slug", + "nullsNotDistinct": false, + "columns": [ + "deployment_slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "deployments_owner_check": { + "name": "deployments_owner_check", + "value": "(\n (\"deployments\".\"owned_by_user_id\" IS NOT NULL AND \"deployments\".\"owned_by_organization_id\" IS NULL) OR\n (\"deployments\".\"owned_by_user_id\" IS NULL AND \"deployments\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "deployments_source_type_check": { + "name": "deployments_source_type_check", + "value": "\"deployments\".\"source_type\" IN ('github', 'git', 'app-builder')" + } + }, + "isRLSEnabled": false + }, + "public.device_auth_requests": { + "name": "device_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_device_auth_requests_code": { + "name": "UQ_device_auth_requests_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_status": { + "name": "IDX_device_auth_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_expires_at": { + "name": "IDX_device_auth_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_kilo_user_id": { + "name": "IDX_device_auth_requests_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_auth_requests_kilo_user_id_kilocode_users_id_fk": { + "name": "device_auth_requests_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "device_auth_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_listener": { + "name": "discord_gateway_listener", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "listener_id": { + "name": "listener_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.editor_name": { + "name": "editor_name", + "schema": "", + "columns": { + "editor_name_id": { + "name": "editor_name_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_editor_name": { + "name": "UQ_editor_name", + "columns": [ + { + "expression": "editor_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_data": { + "name": "enrichment_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_enrichment_data": { + "name": "github_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linkedin_enrichment_data": { + "name": "linkedin_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "clay_enrichment_data": { + "name": "clay_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_enrichment_data_user_id": { + "name": "IDX_enrichment_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_data_user_id_kilocode_users_id_fk": { + "name": "enrichment_data_user_id_kilocode_users_id_fk", + "tableFrom": "enrichment_data", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_enrichment_data_user_id": { + "name": "UQ_enrichment_data_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_monthly_usage": { + "name": "exa_monthly_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_charged_microdollars": { + "name": "total_charged_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "free_allowance_microdollars": { + "name": "free_allowance_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 10000000 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_monthly_usage_personal": { + "name": "idx_exa_monthly_usage_personal", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_exa_monthly_usage_org": { + "name": "idx_exa_monthly_usage_org", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_usage_log": { + "name": "exa_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "charged_to_balance": { + "name": "charged_to_balance", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_usage_log_user_created": { + "name": "idx_exa_usage_log_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "exa_usage_log_id_created_at_pk": { + "name": "exa_usage_log_id_created_at_pk", + "columns": [ + "id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feature": { + "name": "feature", + "schema": "", + "columns": { + "feature_id": { + "name": "feature_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_feature": { + "name": "UQ_feature", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finish_reason": { + "name": "finish_reason", + "schema": "", + "columns": { + "finish_reason_id": { + "name": "finish_reason_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_finish_reason": { + "name": "UQ_finish_reason", + "columns": [ + { + "expression": "finish_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_model_usage": { + "name": "free_model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_model_usage_ip_created_at": { + "name": "idx_free_model_usage_ip_created_at", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_model_usage_created_at": { + "name": "idx_free_model_usage_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_model_usage_user_created_at": { + "name": "idx_free_model_usage_user_created_at", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"free_model_usage\".\"kilo_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.http_ip": { + "name": "http_ip", + "schema": "", + "columns": { + "http_ip_id": { + "name": "http_ip_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_ip": { + "name": "http_ip", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_ip": { + "name": "UQ_http_ip", + "columns": [ + { + "expression": "http_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.http_user_agent": { + "name": "http_user_agent", + "schema": "", + "columns": { + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_user_agent": { + "name": "UQ_http_user_agent", + "columns": [ + { + "expression": "http_user_agent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impact_advocate_participants": { + "name": "impact_advocate_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_id": { + "name": "advocate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_account_id": { + "name": "advocate_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_referral_identifier": { + "name": "opaque_referral_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_state": { + "name": "registration_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_registration_attempt_at": { + "name": "last_registration_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_participants_registration_state": { + "name": "IDX_impact_advocate_participants_registration_state", + "columns": [ + { + "expression": "registration_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_participants_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_participants_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_participants", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_participants_user_id": { + "name": "UQ_impact_advocate_participants_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "UQ_impact_advocate_participants_opaque_referral_identifier": { + "name": "UQ_impact_advocate_participants_opaque_referral_identifier", + "nullsNotDistinct": false, + "columns": [ + "opaque_referral_identifier" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_participants_registration_state_check": { + "name": "impact_advocate_participants_registration_state_check", + "value": "\"impact_advocate_participants\".\"registration_state\" IN ('pending', 'retrying', 'registered', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_registration_attempts": { + "name": "impact_advocate_registration_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_cookie_value": { + "name": "opaque_cookie_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_value_length": { + "name": "cookie_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_registration_attempts_participant_id": { + "name": "IDX_impact_advocate_registration_attempts_participant_id", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_registration_attempts_delivery_state": { + "name": "IDX_impact_advocate_registration_attempts_delivery_state", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk": { + "name": "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk", + "tableFrom": "impact_advocate_registration_attempts", + "tableTo": "impact_advocate_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_registration_attempts_dedupe_key": { + "name": "UQ_impact_advocate_registration_attempts_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_registration_attempts_delivery_state_check": { + "name": "impact_advocate_registration_attempts_delivery_state_check", + "value": "\"impact_advocate_registration_attempts\".\"delivery_state\" IN ('queued', 'sending', 'succeeded', 'failed')" + }, + "impact_advocate_registration_attempts_cookie_value_length_non_negative_check": { + "name": "impact_advocate_registration_attempts_cookie_value_length_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"cookie_value_length\" >= 0" + }, + "impact_advocate_registration_attempts_attempt_count_non_negative_check": { + "name": "impact_advocate_registration_attempts_attempt_count_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_reward_redemptions": { + "name": "impact_advocate_reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "impact_reward_id": { + "name": "impact_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lookup_response_payload": { + "name": "lookup_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "redeem_response_payload": { + "name": "redeem_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_reward_redemptions_beneficiary_user_id": { + "name": "IDX_impact_advocate_reward_redemptions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_reward_redemptions_state": { + "name": "IDX_impact_advocate_reward_redemptions_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_reward_redemptions_reward_id_kiloclaw_referral_rewards_id_fk": { + "name": "impact_advocate_reward_redemptions_reward_id_kiloclaw_referral_rewards_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "kiloclaw_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_reward_redemptions_reward_id": { + "name": "UQ_impact_advocate_reward_redemptions_reward_id", + "nullsNotDistinct": false, + "columns": [ + "reward_id" + ] + }, + "UQ_impact_advocate_reward_redemptions_dedupe_key": { + "name": "UQ_impact_advocate_reward_redemptions_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_reward_redemptions_state_check": { + "name": "impact_advocate_reward_redemptions_state_check", + "value": "\"impact_advocate_reward_redemptions\".\"state\" IN ('queued', 'retrying', 'redeemed', 'failed')" + }, + "impact_advocate_reward_redemptions_attempt_count_non_negative_check": { + "name": "impact_advocate_reward_redemptions_attempt_count_non_negative_check", + "value": "\"impact_advocate_reward_redemptions\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_conversion_reports": { + "name": "impact_conversion_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_tracker_id": { + "name": "action_tracker_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_conversion_reports_conversion_id": { + "name": "IDX_impact_conversion_reports_conversion_id", + "columns": [ + { + "expression": "conversion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_conversion_reports_state": { + "name": "IDX_impact_conversion_reports_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_conversion_reports_conversion_id_kiloclaw_referral_conversions_id_fk": { + "name": "impact_conversion_reports_conversion_id_kiloclaw_referral_conversions_id_fk", + "tableFrom": "impact_conversion_reports", + "tableTo": "kiloclaw_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_conversion_reports_dedupe_key": { + "name": "UQ_impact_conversion_reports_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_conversion_reports_state_check": { + "name": "impact_conversion_reports_state_check", + "value": "\"impact_conversion_reports\".\"state\" IN ('queued', 'retrying', 'delivered', 'failed')" + }, + "impact_conversion_reports_attempt_count_non_negative_check": { + "name": "impact_conversion_reports_attempt_count_non_negative_check", + "value": "\"impact_conversion_reports\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.ja4_digest": { + "name": "ja4_digest", + "schema": "", + "columns": { + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ja4_digest": { + "name": "ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_ja4_digest": { + "name": "UQ_ja4_digest", + "columns": [ + { + "expression": "ja4_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilo_pass_audit_log": { + "name": "kilo_pass_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_credit_transaction_id": { + "name": "related_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "related_monthly_issuance_id": { + "name": "related_monthly_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_kilo_pass_audit_log_created_at": { + "name": "IDX_kilo_pass_audit_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_user_id": { + "name": "IDX_kilo_pass_audit_log_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_pass_subscription_id": { + "name": "IDX_kilo_pass_audit_log_kilo_pass_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_action": { + "name": "IDX_kilo_pass_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_result": { + "name": "IDX_kilo_pass_audit_log_result", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_idempotency_key": { + "name": "IDX_kilo_pass_audit_log_idempotency_key", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_event_id": { + "name": "IDX_kilo_pass_audit_log_stripe_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_invoice_id": { + "name": "IDX_kilo_pass_audit_log_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_subscription_id": { + "name": "IDX_kilo_pass_audit_log_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_credit_transaction_id": { + "name": "IDX_kilo_pass_audit_log_related_credit_transaction_id", + "columns": [ + { + "expression": "related_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_monthly_issuance_id": { + "name": "IDX_kilo_pass_audit_log_related_monthly_issuance_id", + "columns": [ + { + "expression": "related_monthly_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "credit_transactions", + "columnsFrom": [ + "related_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "related_monthly_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_audit_log_action_check": { + "name": "kilo_pass_audit_log_action_check", + "value": "\"kilo_pass_audit_log\".\"action\" IN ('stripe_webhook_received', 'kilo_pass_invoice_paid_handled', 'base_credits_issued', 'bonus_credits_issued', 'bonus_credits_skipped_idempotent', 'first_month_50pct_promo_issued', 'yearly_monthly_base_cron_started', 'yearly_monthly_base_cron_completed', 'issue_yearly_remaining_credits', 'yearly_monthly_bonus_cron_started', 'yearly_monthly_bonus_cron_completed')" + }, + "kilo_pass_audit_log_result_check": { + "name": "kilo_pass_audit_log_result_check", + "value": "\"kilo_pass_audit_log\".\"result\" IN ('success', 'skipped_idempotent', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuance_items": { + "name": "kilo_pass_issuance_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_issuance_id": { + "name": "kilo_pass_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "bonus_percent_applied": { + "name": "bonus_percent_applied", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_issuance_items_issuance_id": { + "name": "IDX_kilo_pass_issuance_items_issuance_id", + "columns": [ + { + "expression": "kilo_pass_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuance_items_credit_transaction_id": { + "name": "IDX_kilo_pass_issuance_items_credit_transaction_id", + "columns": [ + { + "expression": "credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "kilo_pass_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_issuance_items_credit_transaction_id_unique": { + "name": "kilo_pass_issuance_items_credit_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credit_transaction_id" + ] + }, + "UQ_kilo_pass_issuance_items_issuance_kind": { + "name": "UQ_kilo_pass_issuance_items_issuance_kind", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_issuance_id", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuance_items_bonus_percent_applied_range_check": { + "name": "kilo_pass_issuance_items_bonus_percent_applied_range_check", + "value": "\"kilo_pass_issuance_items\".\"bonus_percent_applied\" IS NULL OR (\"kilo_pass_issuance_items\".\"bonus_percent_applied\" >= 0 AND \"kilo_pass_issuance_items\".\"bonus_percent_applied\" <= 1)" + }, + "kilo_pass_issuance_items_amount_usd_non_negative_check": { + "name": "kilo_pass_issuance_items_amount_usd_non_negative_check", + "value": "\"kilo_pass_issuance_items\".\"amount_usd\" >= 0" + }, + "kilo_pass_issuance_items_kind_check": { + "name": "kilo_pass_issuance_items_kind_check", + "value": "\"kilo_pass_issuance_items\".\"kind\" IN ('base', 'bonus', 'promo_first_month_50pct')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuances": { + "name": "kilo_pass_issuances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_month": { + "name": "issue_month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_issuances_stripe_invoice_id": { + "name": "UQ_kilo_pass_issuances_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_issuances\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_subscription_id": { + "name": "IDX_kilo_pass_issuances_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_issue_month": { + "name": "IDX_kilo_pass_issuances_issue_month", + "columns": [ + { + "expression": "issue_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_issuances", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_issuances_subscription_issue_month": { + "name": "UQ_kilo_pass_issuances_subscription_issue_month", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_subscription_id", + "issue_month" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuances_issue_month_day_one_check": { + "name": "kilo_pass_issuances_issue_month_day_one_check", + "value": "EXTRACT(DAY FROM \"kilo_pass_issuances\".\"issue_month\") = 1" + }, + "kilo_pass_issuances_source_check": { + "name": "kilo_pass_issuances_source_check", + "value": "\"kilo_pass_issuances\".\"source\" IN ('stripe_invoice', 'cron')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_pause_events": { + "name": "kilo_pass_pause_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resumes_at": { + "name": "resumes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resumed_at": { + "name": "resumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_pause_events_subscription_id": { + "name": "IDX_kilo_pass_pause_events_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_pause_events_one_open_per_sub": { + "name": "UQ_kilo_pass_pause_events_one_open_per_sub", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_pause_events", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_pause_events_resumed_at_after_paused_at_check": { + "name": "kilo_pass_pause_events_resumed_at_after_paused_at_check", + "value": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL OR \"kilo_pass_pause_events\".\"resumed_at\" >= \"kilo_pass_pause_events\".\"paused_at\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_scheduled_changes": { + "name": "kilo_pass_scheduled_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_tier": { + "name": "from_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_cadence": { + "name": "from_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_tier": { + "name": "to_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_cadence": { + "name": "to_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_scheduled_changes_kilo_user_id": { + "name": "IDX_kilo_pass_scheduled_changes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_status": { + "name": "IDX_kilo_pass_scheduled_changes_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_stripe_subscription_id": { + "name": "IDX_kilo_pass_scheduled_changes_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id": { + "name": "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_scheduled_changes\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_effective_at": { + "name": "IDX_kilo_pass_scheduled_changes_effective_at", + "columns": [ + { + "expression": "effective_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_deleted_at": { + "name": "IDX_kilo_pass_scheduled_changes_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk": { + "name": "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "stripe_subscription_id" + ], + "columnsTo": [ + "stripe_subscription_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_scheduled_changes_from_tier_check": { + "name": "kilo_pass_scheduled_changes_from_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_from_cadence_check": { + "name": "kilo_pass_scheduled_changes_from_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_to_tier_check": { + "name": "kilo_pass_scheduled_changes_to_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_to_cadence_check": { + "name": "kilo_pass_scheduled_changes_to_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_status_check": { + "name": "kilo_pass_scheduled_changes_status_check", + "value": "\"kilo_pass_scheduled_changes\".\"status\" IN ('not_started', 'active', 'completed', 'released', 'canceled')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_subscriptions": { + "name": "kilo_pass_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_streak_months": { + "name": "current_streak_months", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_yearly_issue_at": { + "name": "next_yearly_issue_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_subscriptions_kilo_user_id": { + "name": "IDX_kilo_pass_subscriptions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_status": { + "name": "IDX_kilo_pass_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_cadence": { + "name": "IDX_kilo_pass_subscriptions_cadence", + "columns": [ + { + "expression": "cadence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_subscriptions_stripe_subscription_id_unique": { + "name": "kilo_pass_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_subscriptions_current_streak_months_non_negative_check": { + "name": "kilo_pass_subscriptions_current_streak_months_non_negative_check", + "value": "\"kilo_pass_subscriptions\".\"current_streak_months\" >= 0" + }, + "kilo_pass_subscriptions_tier_check": { + "name": "kilo_pass_subscriptions_tier_check", + "value": "\"kilo_pass_subscriptions\".\"tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_subscriptions_cadence_check": { + "name": "kilo_pass_subscriptions_cadence_check", + "value": "\"kilo_pass_subscriptions\".\"cadence\" IN ('monthly', 'yearly')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_access_codes": { + "name": "kiloclaw_access_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_access_codes_code": { + "name": "UQ_kiloclaw_access_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_access_codes_user_status": { + "name": "IDX_kiloclaw_access_codes_user_status", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_access_codes_one_active_per_user": { + "name": "UQ_kiloclaw_access_codes_one_active_per_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_access_codes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_admin_audit_logs": { + "name": "kiloclaw_admin_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_admin_audit_logs_target_user_id": { + "name": "IDX_kiloclaw_admin_audit_logs_target_user_id", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_action": { + "name": "IDX_kiloclaw_admin_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_created_at": { + "name": "IDX_kiloclaw_admin_audit_logs_created_at", + "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.kiloclaw_attribution_touches": { + "name": "kiloclaw_attribution_touches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touch_type": { + "name": "touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_tracking_value": { + "name": "opaque_tracking_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_value_length": { + "name": "tracking_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_tracking_value_accepted": { + "name": "is_tracking_value_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rs_code": { + "name": "rs_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_share_medium": { + "name": "rs_share_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_engagement_medium": { + "name": "rs_engagement_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "im_ref": { + "name": "im_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_path": { + "name": "landing_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touched_at": { + "name": "touched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sale_attributed_at": { + "name": "sale_attributed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_attribution_touches_user_id": { + "name": "IDX_kiloclaw_attribution_touches_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_attribution_touches_anonymous_id": { + "name": "IDX_kiloclaw_attribution_touches_anonymous_id", + "columns": [ + { + "expression": "anonymous_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_attribution_touches_expires_at": { + "name": "IDX_kiloclaw_attribution_touches_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_attribution_touches_sale_attributed_at": { + "name": "IDX_kiloclaw_attribution_touches_sale_attributed_at", + "columns": [ + { + "expression": "sale_attributed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_attribution_touches_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_attribution_touches_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_attribution_touches", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_attribution_touches_dedupe_key": { + "name": "UQ_kiloclaw_attribution_touches_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_attribution_touches_touch_type_check": { + "name": "kiloclaw_attribution_touches_touch_type_check", + "value": "\"kiloclaw_attribution_touches\".\"touch_type\" IN ('affiliate', 'referral')" + }, + "kiloclaw_attribution_touches_provider_check": { + "name": "kiloclaw_attribution_touches_provider_check", + "value": "\"kiloclaw_attribution_touches\".\"provider\" IN ('impact_performance', 'impact_advocate')" + }, + "kiloclaw_attribution_touches_tracking_value_length_non_negative_check": { + "name": "kiloclaw_attribution_touches_tracking_value_length_non_negative_check", + "value": "\"kiloclaw_attribution_touches\".\"tracking_value_length\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_cli_runs": { + "name": "kiloclaw_cli_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "initiated_by_admin_id": { + "name": "initiated_by_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_cli_runs_user_id": { + "name": "IDX_kiloclaw_cli_runs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_started_at": { + "name": "IDX_kiloclaw_cli_runs_started_at", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_instance_id": { + "name": "IDX_kiloclaw_cli_runs_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_cli_runs_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "initiated_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_earlybird_purchases": { + "name": "kiloclaw_earlybird_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_payment_id": { + "name": "manual_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_earlybird_purchases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_earlybird_purchases_user_id_unique": { + "name": "kiloclaw_earlybird_purchases_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "kiloclaw_earlybird_purchases_stripe_charge_id_unique": { + "name": "kiloclaw_earlybird_purchases_stripe_charge_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_charge_id" + ] + }, + "kiloclaw_earlybird_purchases_manual_payment_id_unique": { + "name": "kiloclaw_earlybird_purchases_manual_payment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "manual_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_email_log": { + "name": "kiloclaw_email_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'epoch'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_email_log_user_type_global": { + "name": "UQ_kiloclaw_email_log_user_type_global", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_email_log_user_instance_type_period": { + "name": "UQ_kiloclaw_email_log_user_instance_type_period", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_email_log_type_sent_instance": { + "name": "IDX_kiloclaw_email_log_type_sent_instance", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_email_log_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_google_oauth_connections": { + "name": "kiloclaw_google_oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_subject": { + "name": "account_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_secret_encrypted": { + "name": "oauth_client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_profile": { + "name": "credential_profile", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kilo_owned'" + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "grants_by_source": { + "name": "grants_by_source", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_google_oauth_connections_instance": { + "name": "UQ_kiloclaw_google_oauth_connections_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_status": { + "name": "IDX_kiloclaw_google_oauth_connections_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_provider": { + "name": "IDX_kiloclaw_google_oauth_connections_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_google_oauth_connections", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_google_oauth_connections_status_check": { + "name": "kiloclaw_google_oauth_connections_status_check", + "value": "\"kiloclaw_google_oauth_connections\".\"status\" IN ('active', 'action_required', 'disconnected')" + }, + "kiloclaw_google_oauth_connections_credential_profile_check": { + "name": "kiloclaw_google_oauth_connections_credential_profile_check", + "value": "\"kiloclaw_google_oauth_connections\".\"credential_profile\" IN ('legacy', 'kilo_owned')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_image_catalog": { + "name": "kiloclaw_image_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_digest": { + "name": "image_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rollout_percent": { + "name": "rollout_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_latest": { + "name": "is_latest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_kiloclaw_image_catalog_status": { + "name": "IDX_kiloclaw_image_catalog_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_image_catalog_variant": { + "name": "IDX_kiloclaw_image_catalog_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_latest_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_latest_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_candidate_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_candidate_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = false AND \"kiloclaw_image_catalog\".\"rollout_percent\" > 0 AND \"kiloclaw_image_catalog\".\"status\" = 'available'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_image_catalog_image_tag_unique": { + "name": "kiloclaw_image_catalog_image_tag_unique", + "nullsNotDistinct": false, + "columns": [ + "image_tag" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_aliases": { + "name": "kiloclaw_inbound_email_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_inbound_email_aliases_instance_id": { + "name": "IDX_kiloclaw_inbound_email_aliases_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_inbound_email_aliases_active_instance": { + "name": "UQ_kiloclaw_inbound_email_aliases_active_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_inbound_email_aliases\".\"retired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_inbound_email_aliases", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_reserved_aliases": { + "name": "kiloclaw_inbound_email_reserved_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_instances": { + "name": "kiloclaw_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fly'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_email_enabled": { + "name": "inbound_email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inactive_trial_stopped_at": { + "name": "inactive_trial_stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tracked_image_tag": { + "name": "tracked_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_size_override": { + "name": "admin_size_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_instances_active": { + "name": "UQ_kiloclaw_instances_active", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sandbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_personal_by_user": { + "name": "IDX_kiloclaw_instances_active_personal_by_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_org_by_user_org": { + "name": "IDX_kiloclaw_instances_active_org_by_user_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_tracked_image_tag": { + "name": "IDX_kiloclaw_instances_tracked_image_tag", + "columns": [ + { + "expression": "tracked_image_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_instance_type": { + "name": "IDX_kiloclaw_instances_instance_type", + "columns": [ + { + "expression": "instance_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_admin_size_override": { + "name": "IDX_kiloclaw_instances_admin_size_override", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"admin_size_override\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_instances_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_instances_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_instances_organization_id_organizations_id_fk": { + "name": "kiloclaw_instances_organization_id_organizations_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_kiloclaw_instances_instance_type": { + "name": "CHK_kiloclaw_instances_instance_type", + "value": "\"kiloclaw_instances\".\"instance_type\" IS NULL OR \"kiloclaw_instances\".\"instance_type\" IN ('perf-1-3', 'perf-4-8', 'perf-4-16', 'shared-2-3', 'shared-2-4', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_conversions": { + "name": "kiloclaw_referral_conversions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "winning_touch_type": { + "name": "winning_touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_payment_id": { + "name": "source_payment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qualified": { + "name": "qualified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disqualification_reason": { + "name": "disqualification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "converted_at": { + "name": "converted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_conversions_referee_user_id": { + "name": "IDX_kiloclaw_referral_conversions_referee_user_id", + "columns": [ + { + "expression": "referee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referral_conversions_referrer_user_id": { + "name": "IDX_kiloclaw_referral_conversions_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_conversions_referee_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_conversions_referee_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_conversions_referrer_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_conversions_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kiloclaw_referral_conversions_source_touch_id_kiloclaw_attribution_touches_id_fk": { + "name": "kiloclaw_referral_conversions_source_touch_id_kiloclaw_attribution_touches_id_fk", + "tableFrom": "kiloclaw_referral_conversions", + "tableTo": "kiloclaw_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referral_conversions_source_payment_id": { + "name": "UQ_kiloclaw_referral_conversions_source_payment_id", + "nullsNotDistinct": false, + "columns": [ + "source_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_referral_conversions_winning_touch_type_check": { + "name": "kiloclaw_referral_conversions_winning_touch_type_check", + "value": "\"kiloclaw_referral_conversions\".\"winning_touch_type\" IN ('referral', 'affiliate', 'none')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_reward_applications": { + "name": "kiloclaw_referral_reward_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "previous_renewal_boundary": { + "name": "previous_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "new_renewal_boundary": { + "name": "new_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "local_operation_id": { + "name": "local_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_operation_id": { + "name": "stripe_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_idempotency_key": { + "name": "stripe_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_reward_applications_reward_id": { + "name": "IDX_kiloclaw_referral_reward_applications_reward_id", + "columns": [ + { + "expression": "reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referral_reward_applications_beneficiary_user_id": { + "name": "IDX_kiloclaw_referral_reward_applications_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_reward_applications_reward_id_kiloclaw_referral_rewards_id_fk": { + "name": "kiloclaw_referral_reward_applications_reward_id_kiloclaw_referral_rewards_id_fk", + "tableFrom": "kiloclaw_referral_reward_applications", + "tableTo": "kiloclaw_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_reward_applications", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_reward_decisions": { + "name": "kiloclaw_referral_reward_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id": { + "name": "IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_reward_decisions_conversion_id_kiloclaw_referral_conversions_id_fk": { + "name": "kiloclaw_referral_reward_decisions_conversion_id_kiloclaw_referral_conversions_id_fk", + "tableFrom": "kiloclaw_referral_reward_decisions", + "tableTo": "kiloclaw_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_reward_decisions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referral_reward_decisions_conversion_role": { + "name": "UQ_kiloclaw_referral_reward_decisions_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_referral_reward_decisions_beneficiary_role_check": { + "name": "kiloclaw_referral_reward_decisions_beneficiary_role_check", + "value": "\"kiloclaw_referral_reward_decisions\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "kiloclaw_referral_reward_decisions_outcome_check": { + "name": "kiloclaw_referral_reward_decisions_outcome_check", + "value": "\"kiloclaw_referral_reward_decisions\".\"outcome\" IN ('granted', 'cap_limited', 'disqualified')" + }, + "kiloclaw_referral_reward_decisions_months_granted_non_negative_check": { + "name": "kiloclaw_referral_reward_decisions_months_granted_non_negative_check", + "value": "\"kiloclaw_referral_reward_decisions\".\"months_granted\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_rewards": { + "name": "kiloclaw_referral_rewards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "applies_to_subscription_id": { + "name": "applies_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "earned_at": { + "name": "earned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reversed_at": { + "name": "reversed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_rewards_beneficiary_user_id": { + "name": "IDX_kiloclaw_referral_rewards_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referral_rewards_status": { + "name": "IDX_kiloclaw_referral_rewards_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_rewards_conversion_id_kiloclaw_referral_conversions_id_fk": { + "name": "kiloclaw_referral_rewards_conversion_id_kiloclaw_referral_conversions_id_fk", + "tableFrom": "kiloclaw_referral_rewards", + "tableTo": "kiloclaw_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_rewards_decision_id_kiloclaw_referral_reward_decisions_id_fk": { + "name": "kiloclaw_referral_rewards_decision_id_kiloclaw_referral_reward_decisions_id_fk", + "tableFrom": "kiloclaw_referral_rewards", + "tableTo": "kiloclaw_referral_reward_decisions", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_rewards_beneficiary_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_rewards_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_rewards", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referral_rewards_conversion_role": { + "name": "UQ_kiloclaw_referral_rewards_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + }, + "UQ_kiloclaw_referral_rewards_decision_id": { + "name": "UQ_kiloclaw_referral_rewards_decision_id", + "nullsNotDistinct": false, + "columns": [ + "decision_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_referral_rewards_beneficiary_role_check": { + "name": "kiloclaw_referral_rewards_beneficiary_role_check", + "value": "\"kiloclaw_referral_rewards\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "kiloclaw_referral_rewards_status_check": { + "name": "kiloclaw_referral_rewards_status_check", + "value": "\"kiloclaw_referral_rewards\".\"status\" IN ('pending', 'earned', 'applied', 'reversed', 'expired', 'canceled', 'review_required')" + }, + "kiloclaw_referral_rewards_months_granted_positive_check": { + "name": "kiloclaw_referral_rewards_months_granted_positive_check", + "value": "\"kiloclaw_referral_rewards\".\"months_granted\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referrals": { + "name": "kiloclaw_referrals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "impact_referral_id": { + "name": "impact_referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referrals_referrer_user_id": { + "name": "IDX_kiloclaw_referrals_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referrals_source_touch_id": { + "name": "IDX_kiloclaw_referrals_source_touch_id", + "columns": [ + { + "expression": "source_touch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referrals_referee_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referrals_referee_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referrals_referrer_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referrals_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kiloclaw_referrals_source_touch_id_kiloclaw_attribution_touches_id_fk": { + "name": "kiloclaw_referrals_source_touch_id_kiloclaw_attribution_touches_id_fk", + "tableFrom": "kiloclaw_referrals", + "tableTo": "kiloclaw_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referrals_referee_user_id": { + "name": "UQ_kiloclaw_referrals_referee_user_id", + "nullsNotDistinct": false, + "columns": [ + "referee_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_notifications": { + "name": "kiloclaw_scheduled_action_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'notice'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel": { + "name": "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_notifications_pending": { + "name": "IDX_kiloclaw_scheduled_action_notifications_pending", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_notifications\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk": { + "name": "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk", + "tableFrom": "kiloclaw_scheduled_action_notifications", + "tableTo": "kiloclaw_scheduled_action_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_stages": { + "name": "kiloclaw_scheduled_action_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_index": { + "name": "stage_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notice_sent_at": { + "name": "notice_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_stages_parent_index": { + "name": "UQ_kiloclaw_scheduled_action_stages_parent_index", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_stages_notice_due": { + "name": "IDX_kiloclaw_scheduled_action_stages_notice_due", + "columns": [ + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_stages\".\"notice_sent_at\" IS NULL AND \"kiloclaw_scheduled_action_stages\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_stages", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_targets": { + "name": "kiloclaw_scheduled_action_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_image_tag": { + "name": "source_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_targets_parent_instance": { + "name": "UQ_kiloclaw_scheduled_action_targets_parent_instance", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_stage": { + "name": "IDX_kiloclaw_scheduled_action_targets_stage", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_pending_by_instance": { + "name": "IDX_kiloclaw_scheduled_action_targets_pending_by_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_targets\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk": { + "name": "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_action_stages", + "columnsFrom": [ + "stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_actions": { + "name": "kiloclaw_scheduled_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_pins": { + "name": "override_pins", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notice_lead_hours": { + "name": "notice_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "notice_subject": { + "name": "notice_subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "notice_body": { + "name": "notice_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "IDX_kiloclaw_scheduled_actions_status": { + "name": "IDX_kiloclaw_scheduled_actions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_action_type": { + "name": "IDX_kiloclaw_scheduled_actions_action_type", + "columns": [ + { + "expression": "action_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_created_by": { + "name": "IDX_kiloclaw_scheduled_actions_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "target_image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_subscription_change_log": { + "name": "kiloclaw_subscription_change_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_subscription_change_log_subscription_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_subscription_created_at", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscription_change_log_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscription_change_log", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscription_change_log_actor_type_check": { + "name": "kiloclaw_subscription_change_log_actor_type_check", + "value": "\"kiloclaw_subscription_change_log\".\"actor_type\" IN ('user', 'system')" + }, + "kiloclaw_subscription_change_log_action_check": { + "name": "kiloclaw_subscription_change_log_action_check", + "value": "\"kiloclaw_subscription_change_log\".\"action\" IN ('created', 'status_changed', 'plan_switched', 'period_advanced', 'canceled', 'reactivated', 'suspended', 'destruction_scheduled', 'reassigned', 'backfilled', 'payment_source_changed', 'schedule_changed', 'admin_override')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_subscriptions": { + "name": "kiloclaw_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transferred_to_subscription_id": { + "name": "transferred_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_origin": { + "name": "access_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_source": { + "name": "payment_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_plan": { + "name": "scheduled_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_by": { + "name": "scheduled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_conversion": { + "name": "pending_conversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trial_started_at": { + "name": "trial_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_renewal_at": { + "name": "credit_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commit_ends_at": { + "name": "commit_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "destruction_deadline": { + "name": "destruction_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_requested_at": { + "name": "auto_resume_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_retry_after": { + "name": "auto_resume_retry_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_attempt_count": { + "name": "auto_resume_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "auto_top_up_triggered_for_period": { + "name": "auto_top_up_triggered_for_period", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_subscriptions_status": { + "name": "IDX_kiloclaw_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_id": { + "name": "IDX_kiloclaw_subscriptions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_status": { + "name": "IDX_kiloclaw_subscriptions_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_transferred_to": { + "name": "IDX_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_stripe_schedule_id": { + "name": "IDX_kiloclaw_subscriptions_stripe_schedule_id", + "columns": [ + { + "expression": "stripe_schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_auto_resume_retry_after": { + "name": "IDX_kiloclaw_subscriptions_auto_resume_retry_after", + "columns": [ + { + "expression": "auto_resume_retry_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_instance": { + "name": "UQ_kiloclaw_subscriptions_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_transferred_to": { + "name": "UQ_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"transferred_to_subscription_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_earlybird_origin": { + "name": "IDX_kiloclaw_subscriptions_earlybird_origin", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "access_origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_subscriptions\".\"access_origin\" = 'earlybird'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscriptions_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_subscriptions_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "transferred_to_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_subscriptions_stripe_subscription_id_unique": { + "name": "kiloclaw_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscriptions_plan_check": { + "name": "kiloclaw_subscriptions_plan_check", + "value": "\"kiloclaw_subscriptions\".\"plan\" IN ('trial', 'commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_plan_check": { + "name": "kiloclaw_subscriptions_scheduled_plan_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_plan\" IN ('commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_by_check": { + "name": "kiloclaw_subscriptions_scheduled_by_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_by\" IN ('auto', 'user')" + }, + "kiloclaw_subscriptions_status_check": { + "name": "kiloclaw_subscriptions_status_check", + "value": "\"kiloclaw_subscriptions\".\"status\" IN ('trialing', 'active', 'past_due', 'canceled', 'unpaid')" + }, + "kiloclaw_subscriptions_access_origin_check": { + "name": "kiloclaw_subscriptions_access_origin_check", + "value": "\"kiloclaw_subscriptions\".\"access_origin\" IN ('earlybird')" + }, + "kiloclaw_subscriptions_payment_source_check": { + "name": "kiloclaw_subscriptions_payment_source_check", + "value": "\"kiloclaw_subscriptions\".\"payment_source\" IN ('stripe', 'credits')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_version_pins": { + "name": "kiloclaw_version_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_by": { + "name": "pinned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk": { + "name": "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kilocode_users", + "columnsFrom": [ + "pinned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_version_pins_instance_id_unique": { + "name": "kiloclaw_version_pins_instance_id_unique", + "nullsNotDistinct": false, + "columns": [ + "instance_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilocode_users": { + "name": "kilocode_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "google_user_email": { + "name": "google_user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_name": { + "name": "google_user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_image_url": { + "name": "google_user_image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "kilo_pass_threshold": { + "name": "kilo_pass_threshold", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_validation_stytch": { + "name": "has_validation_stytch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_validation_novel_card_with_hold": { + "name": "has_validation_novel_card_with_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_by_kilo_user_id": { + "name": "blocked_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_token_pepper": { + "name": "api_token_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_session_pepper": { + "name": "web_session_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kiloclaw_early_access": { + "name": "kiloclaw_early_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cohorts": { + "name": "cohorts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_welcome_form": { + "name": "completed_welcome_form", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_server_membership_verified_at": { + "name": "discord_server_membership_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "openrouter_upstream_safety_identifier": { + "name": "openrouter_upstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vercel_downstream_safety_identifier": { + "name": "vercel_downstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_source": { + "name": "customer_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_ip": { + "name": "signup_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_deletion_requested_at": { + "name": "account_deletion_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kilocode_users_signup_ip_created_at": { + "name": "IDX_kilocode_users_signup_ip_created_at", + "columns": [ + { + "expression": "signup_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_at": { + "name": "IDX_kilocode_users_blocked_at", + "columns": [ + { + "expression": "blocked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_by_kilo_user_id": { + "name": "IDX_kilocode_users_blocked_by_kilo_user_id", + "columns": [ + { + "expression": "blocked_by_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_openrouter_upstream_safety_identifier": { + "name": "UQ_kilocode_users_openrouter_upstream_safety_identifier", + "columns": [ + { + "expression": "openrouter_upstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"openrouter_upstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_vercel_downstream_safety_identifier": { + "name": "UQ_kilocode_users_vercel_downstream_safety_identifier", + "columns": [ + { + "expression": "vercel_downstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"vercel_downstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_normalized_email": { + "name": "IDX_kilocode_users_normalized_email", + "columns": [ + { + "expression": "normalized_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_email_domain": { + "name": "IDX_kilocode_users_email_domain", + "columns": [ + { + "expression": "email_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_b1afacbcf43f2c7c4cb9f7e7faa": { + "name": "UQ_b1afacbcf43f2c7c4cb9f7e7faa", + "nullsNotDistinct": false, + "columns": [ + "google_user_email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "blocked_reason_not_empty": { + "name": "blocked_reason_not_empty", + "value": "length(blocked_reason) > 0" + } + }, + "isRLSEnabled": false + }, + "public.magic_link_tokens": { + "name": "magic_link_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_magic_link_tokens_email": { + "name": "idx_magic_link_tokens_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_magic_link_tokens_expires_at": { + "name": "idx_magic_link_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_expires_at_future": { + "name": "check_expires_at_future", + "value": "\"magic_link_tokens\".\"expires_at\" > \"magic_link_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.microdollar_usage": { + "name": "microdollar_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_created_at": { + "name": "idx_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_abuse_classification": { + "name": "idx_abuse_classification", + "columns": [ + { + "expression": "abuse_classification", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id_created_at2": { + "name": "idx_kilo_user_id_created_at2", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_organization_id": { + "name": "idx_microdollar_usage_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"microdollar_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microdollar_usage_metadata": { + "name": "microdollar_usage_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_ip_id": { + "name": "http_ip_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_latitude": { + "name": "vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_longitude": { + "name": "vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason_id": { + "name": "finish_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name_id": { + "name": "editor_name_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_kind_id": { + "name": "api_kind_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_model_id": { + "name": "auto_model_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_microdollar_usage_metadata_created_at": { + "name": "idx_microdollar_usage_metadata_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk": { + "name": "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_user_agent", + "columnsFrom": [ + "http_user_agent_id" + ], + "columnsTo": [ + "http_user_agent_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk": { + "name": "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_ip", + "columnsFrom": [ + "http_ip_id" + ], + "columnsTo": [ + "http_ip_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_city", + "columnsFrom": [ + "vercel_ip_city_id" + ], + "columnsTo": [ + "vercel_ip_city_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_country", + "columnsFrom": [ + "vercel_ip_country_id" + ], + "columnsTo": [ + "vercel_ip_country_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk": { + "name": "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "ja4_digest", + "columnsFrom": [ + "ja4_digest_id" + ], + "columnsTo": [ + "ja4_digest_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk": { + "name": "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "system_prompt_prefix", + "columnsFrom": [ + "system_prompt_prefix_id" + ], + "columnsTo": [ + "system_prompt_prefix_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mode": { + "name": "mode", + "schema": "", + "columns": { + "mode_id": { + "name": "mode_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_mode": { + "name": "UQ_mode", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_stats": { + "name": "model_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_stealth": { + "name": "is_stealth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recommended": { + "name": "is_recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "openrouter_id": { + "name": "openrouter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aa_slug": { + "name": "aa_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_creator": { + "name": "model_creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_slug": { + "name": "creator_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "price_input": { + "name": "price_input", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "price_output": { + "name": "price_output", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "coding_index": { + "name": "coding_index", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "speed_tokens_per_sec": { + "name": "speed_tokens_per_sec", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "openrouter_data": { + "name": "openrouter_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "benchmarks": { + "name": "benchmarks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "chart_data": { + "name": "chart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_stats_openrouter_id": { + "name": "IDX_model_stats_openrouter_id", + "columns": [ + { + "expression": "openrouter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_slug": { + "name": "IDX_model_stats_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_is_active": { + "name": "IDX_model_stats_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_creator_slug": { + "name": "IDX_model_stats_creator_slug", + "columns": [ + { + "expression": "creator_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_price_input": { + "name": "IDX_model_stats_price_input", + "columns": [ + { + "expression": "price_input", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_coding_index": { + "name": "IDX_model_stats_coding_index", + "columns": [ + { + "expression": "coding_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_context_length": { + "name": "IDX_model_stats_context_length", + "columns": [ + { + "expression": "context_length", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_stats_openrouter_id_unique": { + "name": "model_stats_openrouter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "openrouter_id" + ] + }, + "model_stats_slug_unique": { + "name": "model_stats_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models_by_provider": { + "name": "models_by_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "openrouter": { + "name": "openrouter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "vercel": { + "name": "vercel", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_audit_logs": { + "name": "organization_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_audit_logs_organization_id": { + "name": "IDX_organization_audit_logs_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_action": { + "name": "IDX_organization_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_actor_id": { + "name": "IDX_organization_audit_logs_actor_id", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_created_at": { + "name": "IDX_organization_audit_logs_created_at", + "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.organization_invitations": { + "name": "organization_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_invitations_token": { + "name": "UQ_organization_invitations_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_org_id": { + "name": "IDX_organization_invitations_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_email": { + "name": "IDX_organization_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_expires_at": { + "name": "IDX_organization_invitations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_membership_removals": { + "name": "organization_membership_removals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_by": { + "name": "removed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_org_membership_removals_org_id": { + "name": "IDX_org_membership_removals_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_org_membership_removals_user_id": { + "name": "IDX_org_membership_removals_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_org_membership_removals_org_user": { + "name": "UQ_org_membership_removals_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_memberships": { + "name": "organization_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_memberships_org_id": { + "name": "IDX_organization_memberships_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_memberships_user_id": { + "name": "IDX_organization_memberships_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_memberships_org_user": { + "name": "UQ_organization_memberships_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_seats_purchases": { + "name": "organization_seats_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_stripe_id": { + "name": "subscription_stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + } + }, + "indexes": { + "IDX_organization_seats_org_id": { + "name": "IDX_organization_seats_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_expires_at": { + "name": "IDX_organization_seats_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_created_at": { + "name": "IDX_organization_seats_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_updated_at": { + "name": "IDX_organization_seats_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_starts_at": { + "name": "IDX_organization_seats_starts_at", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_seats_idempotency_key": { + "name": "UQ_organization_seats_idempotency_key", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_limits": { + "name": "organization_user_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_limit": { + "name": "microdollar_limit", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_user_limits_org_id": { + "name": "IDX_organization_user_limits_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_limits_user_id": { + "name": "IDX_organization_user_limits_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_limits_org_user": { + "name": "UQ_organization_user_limits_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_usage": { + "name": "organization_user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_usage": { + "name": "microdollar_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_user_daily_usage_org_id": { + "name": "IDX_organization_user_daily_usage_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_daily_usage_user_id": { + "name": "IDX_organization_user_daily_usage_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_daily_usage_org_user_date": { + "name": "UQ_organization_user_daily_usage_org_user_date", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type", + "usage_date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "microdollars_balance": { + "name": "microdollars_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_seats": { + "name": "require_seats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sso_domain": { + "name": "sso_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'teams'" + }, + "free_trial_end_at": { + "name": "free_trial_end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "company_domain": { + "name": "company_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_organizations_sso_domain": { + "name": "IDX_organizations_sso_domain", + "columns": [ + { + "expression": "sso_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organizations_name_not_empty_check": { + "name": "organizations_name_not_empty_check", + "value": "length(trim(\"organizations\".\"name\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.organization_modes": { + "name": "organization_modes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_organization_modes_organization_id": { + "name": "IDX_organization_modes_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_modes_org_id_slug": { + "name": "UQ_organization_modes_org_id_slug", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_fingerprint": { + "name": "stripe_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_country": { + "name": "address_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "three_d_secure_supported": { + "name": "three_d_secure_supported", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "regulated_status": { + "name": "regulated_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1_check_status": { + "name": "address_line1_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_check_status": { + "name": "postal_code_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligible_for_free_credits": { + "name": "eligible_for_free_credits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_data": { + "name": "stripe_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_d7d7fb15569674aaadcfbc0428": { + "name": "IDX_d7d7fb15569674aaadcfbc0428", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_e1feb919d0ab8a36381d5d5138": { + "name": "IDX_e1feb919d0ab8a36381d5d5138", + "columns": [ + { + "expression": "stripe_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_payment_methods_organization_id": { + "name": "IDX_payment_methods_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_29df1b0403df5792c96bbbfdbe6": { + "name": "UQ_29df1b0403df5792c96bbbfdbe6", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_impact_sale_reversals": { + "name": "pending_impact_sale_reversals", + "schema": "", + "columns": { + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "dispute_id": { + "name": "dispute_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_impact_sale_reversals_attempt_count_non_negative_check": { + "name": "pending_impact_sale_reversals_attempt_count_non_negative_check", + "value": "\"pending_impact_sale_reversals\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.platform_integrations": { + "name": "platform_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_installation_id": { + "name": "platform_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_id": { + "name": "platform_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_login": { + "name": "platform_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "repository_access": { + "name": "repository_access", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "repositories_synced_at": { + "name": "repositories_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kilo_requester_user_id": { + "name": "kilo_requester_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_requester_account_id": { + "name": "platform_requester_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_status": { + "name": "integration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'standard'" + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_integrations_owned_by_org_platform_inst": { + "name": "UQ_platform_integrations_owned_by_org_platform_inst", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_owned_by_user_platform_inst": { + "name": "UQ_platform_integrations_owned_by_user_platform_inst", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_slack_platform_inst": { + "name": "UQ_platform_integrations_slack_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'slack' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_linear_platform_inst": { + "name": "UQ_platform_integrations_linear_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'linear' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_id": { + "name": "IDX_platform_integrations_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_id": { + "name": "IDX_platform_integrations_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_inst_id": { + "name": "IDX_platform_integrations_platform_inst_id", + "columns": [ + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform": { + "name": "IDX_platform_integrations_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_platform": { + "name": "IDX_platform_integrations_owned_by_org_platform", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_platform": { + "name": "IDX_platform_integrations_owned_by_user_platform", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_integration_status": { + "name": "IDX_platform_integrations_integration_status", + "columns": [ + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_kilo_requester": { + "name": "IDX_platform_integrations_kilo_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_requester_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_requester": { + "name": "IDX_platform_integrations_platform_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_requester_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_integrations_owned_by_organization_id_organizations_id_fk": { + "name": "platform_integrations_owned_by_organization_id_organizations_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_integrations_owned_by_user_id_kilocode_users_id_fk": { + "name": "platform_integrations_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_integrations_owner_check": { + "name": "platform_integrations_owner_check", + "value": "(\n (\"platform_integrations\".\"owned_by_user_id\" IS NOT NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NULL) OR\n (\"platform_integrations\".\"owned_by_user_id\" IS NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.referral_code_usages": { + "name": "referral_code_usages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referring_kilo_user_id": { + "name": "referring_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeeming_kilo_user_id": { + "name": "redeeming_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_referral_code_usages_redeeming_kilo_user_id": { + "name": "IDX_referral_code_usages_redeeming_kilo_user_id", + "columns": [ + { + "expression": "redeeming_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_referral_code_usages_redeeming_user_id_code": { + "name": "UQ_referral_code_usages_redeeming_user_id_code", + "nullsNotDistinct": false, + "columns": [ + "redeeming_kilo_user_id", + "referring_kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_referral_codes_kilo_user_id": { + "name": "UQ_referral_codes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_referral_codes_code": { + "name": "IDX_referral_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_check_catalog": { + "name": "security_advisor_check_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_check_catalog_check_id_unique": { + "name": "security_advisor_check_catalog_check_id_unique", + "nullsNotDistinct": false, + "columns": [ + "check_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "security_advisor_check_catalog_severity_check": { + "name": "security_advisor_check_catalog_severity_check", + "value": "\"security_advisor_check_catalog\".\"severity\" in ('critical', 'warn', 'info')" + } + }, + "isRLSEnabled": false + }, + "public.security_advisor_content": { + "name": "security_advisor_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_content_key_unique": { + "name": "security_advisor_content_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_kiloclaw_coverage": { + "name": "security_advisor_kiloclaw_coverage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_check_ids": { + "name": "match_check_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_kiloclaw_coverage_area_unique": { + "name": "security_advisor_kiloclaw_coverage_area_unique", + "nullsNotDistinct": false, + "columns": [ + "area" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_scans": { + "name": "security_advisor_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_platform": { + "name": "source_platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_method": { + "name": "source_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_ip": { + "name": "public_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings_critical": { + "name": "findings_critical", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_warn": { + "name": "findings_warn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_info": { + "name": "findings_info", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_advisor_scans_user_created_at": { + "name": "idx_security_advisor_scans_user_created_at", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_created_at": { + "name": "idx_security_advisor_scans_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_platform": { + "name": "idx_security_advisor_scans_platform", + "columns": [ + { + "expression": "source_platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_analysis_owner_state": { + "name": "security_analysis_owner_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_analysis_enabled_at": { + "name": "auto_analysis_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "block_reason": { + "name": "block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_actor_resolution_failures": { + "name": "consecutive_actor_resolution_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_actor_resolution_failure_at": { + "name": "last_actor_resolution_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_analysis_owner_state_org_owner": { + "name": "UQ_security_analysis_owner_state_org_owner", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_analysis_owner_state_user_owner": { + "name": "UQ_security_analysis_owner_state_user_owner", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk": { + "name": "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_analysis_owner_state", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_analysis_owner_state", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_analysis_owner_state_owner_check": { + "name": "security_analysis_owner_state_owner_check", + "value": "(\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_analysis_owner_state_block_reason_check": { + "name": "security_analysis_owner_state_block_reason_check", + "value": "\"security_analysis_owner_state\".\"block_reason\" IS NULL OR \"security_analysis_owner_state\".\"block_reason\" IN ('INSUFFICIENT_CREDITS', 'ACTOR_RESOLUTION_FAILED', 'OPERATOR_PAUSE')" + } + }, + "isRLSEnabled": false + }, + "public.security_analysis_queue": { + "name": "security_analysis_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queue_status": { + "name": "queue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity_rank": { + "name": "severity_rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by_job_id": { + "name": "claimed_by_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reopen_requeue_count": { + "name": "reopen_requeue_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_analysis_queue_finding_id": { + "name": "UQ_security_analysis_queue_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_claim_path_org": { + "name": "idx_security_analysis_queue_claim_path_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "severity_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_claim_path_user": { + "name": "idx_security_analysis_queue_claim_path_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "severity_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_in_flight_org": { + "name": "idx_security_analysis_queue_in_flight_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queue_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_in_flight_user": { + "name": "idx_security_analysis_queue_in_flight_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queue_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_lag_dashboards": { + "name": "idx_security_analysis_queue_lag_dashboards", + "columns": [ + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_pending_reconciliation": { + "name": "idx_security_analysis_queue_pending_reconciliation", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_running_reconciliation": { + "name": "idx_security_analysis_queue_running_reconciliation", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_failure_trend": { + "name": "idx_security_analysis_queue_failure_trend", + "columns": [ + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"failure_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_analysis_queue_finding_id_security_findings_id_fk": { + "name": "security_analysis_queue_finding_id_security_findings_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_queue_owned_by_organization_id_organizations_id_fk": { + "name": "security_analysis_queue_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_analysis_queue_owner_check": { + "name": "security_analysis_queue_owner_check", + "value": "(\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_analysis_queue_status_check": { + "name": "security_analysis_queue_status_check", + "value": "\"security_analysis_queue\".\"queue_status\" IN ('queued', 'pending', 'running', 'failed', 'completed')" + }, + "security_analysis_queue_claim_token_required_check": { + "name": "security_analysis_queue_claim_token_required_check", + "value": "\"security_analysis_queue\".\"queue_status\" NOT IN ('pending', 'running') OR \"security_analysis_queue\".\"claim_token\" IS NOT NULL" + }, + "security_analysis_queue_attempt_count_non_negative_check": { + "name": "security_analysis_queue_attempt_count_non_negative_check", + "value": "\"security_analysis_queue\".\"attempt_count\" >= 0" + }, + "security_analysis_queue_reopen_requeue_count_non_negative_check": { + "name": "security_analysis_queue_reopen_requeue_count_non_negative_check", + "value": "\"security_analysis_queue\".\"reopen_requeue_count\" >= 0" + }, + "security_analysis_queue_severity_rank_check": { + "name": "security_analysis_queue_severity_rank_check", + "value": "\"security_analysis_queue\".\"severity_rank\" IN (0, 1, 2, 3)" + }, + "security_analysis_queue_failure_code_check": { + "name": "security_analysis_queue_failure_code_check", + "value": "\"security_analysis_queue\".\"failure_code\" IS NULL OR \"security_analysis_queue\".\"failure_code\" IN (\n 'NETWORK_TIMEOUT',\n 'UPSTREAM_5XX',\n 'TEMP_TOKEN_FAILURE',\n 'START_CALL_AMBIGUOUS',\n 'REQUEUE_TEMPORARY_PRECONDITION',\n 'ACTOR_RESOLUTION_FAILED',\n 'GITHUB_TOKEN_UNAVAILABLE',\n 'INVALID_CONFIG',\n 'MISSING_OWNERSHIP',\n 'PERMISSION_DENIED_PERMANENT',\n 'UNSUPPORTED_SEVERITY',\n 'INSUFFICIENT_CREDITS',\n 'STATE_GUARD_REJECTED',\n 'SKIPPED_ALREADY_IN_PROGRESS',\n 'SKIPPED_NO_LONGER_ELIGIBLE',\n 'REOPEN_LOOP_GUARD',\n 'RUN_LOST'\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_audit_log": { + "name": "security_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_security_audit_log_org_created": { + "name": "IDX_security_audit_log_org_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_user_created": { + "name": "IDX_security_audit_log_user_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_resource": { + "name": "IDX_security_audit_log_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_actor": { + "name": "IDX_security_audit_log_actor", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_action": { + "name": "IDX_security_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_audit_log_owned_by_organization_id_organizations_id_fk": { + "name": "security_audit_log_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_audit_log_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_audit_log_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_audit_log_owner_check": { + "name": "security_audit_log_owner_check", + "value": "(\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NULL) OR (\"security_audit_log\".\"owned_by_user_id\" IS NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL)" + }, + "security_audit_log_action_check": { + "name": "security_audit_log_action_check", + "value": "\"security_audit_log\".\"action\" IN ('security.finding.created', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" + } + }, + "isRLSEnabled": false + }, + "public.security_findings": { + "name": "security_findings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ghsa_id": { + "name": "ghsa_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cve_id": { + "name": "cve_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_ecosystem": { + "name": "package_ecosystem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vulnerable_version_range": { + "name": "vulnerable_version_range", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patched_version": { + "name": "patched_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_path": { + "name": "manifest_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "ignored_reason": { + "name": "ignored_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignored_by": { + "name": "ignored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fixed_at": { + "name": "fixed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sla_due_at": { + "name": "sla_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dependabot_html_url": { + "name": "dependabot_html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwe_ids": { + "name": "cwe_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cvss_score": { + "name": "cvss_score", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": false + }, + "dependency_scope": { + "name": "dependency_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_status": { + "name": "analysis_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_started_at": { + "name": "analysis_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_completed_at": { + "name": "analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_error": { + "name": "analysis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis": { + "name": "analysis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_detected_at": { + "name": "first_detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_findings_org_id": { + "name": "idx_security_findings_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_id": { + "name": "idx_security_findings_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_repo": { + "name": "idx_security_findings_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_severity": { + "name": "idx_security_findings_severity", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_status": { + "name": "idx_security_findings_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_package": { + "name": "idx_security_findings_package", + "columns": [ + { + "expression": "package_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_sla_due_at": { + "name": "idx_security_findings_sla_due_at", + "columns": [ + { + "expression": "sla_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_session_id": { + "name": "idx_security_findings_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_cli_session_id": { + "name": "idx_security_findings_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_analysis_status": { + "name": "idx_security_findings_analysis_status", + "columns": [ + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_org_analysis_in_flight": { + "name": "idx_security_findings_org_analysis_in_flight", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_analysis_in_flight": { + "name": "idx_security_findings_user_analysis_in_flight", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_findings_owned_by_organization_id_organizations_id_fk": { + "name": "security_findings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_findings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_findings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_findings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_platform_integration_id_platform_integrations_id_fk": { + "name": "security_findings_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "security_findings", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_security_findings_source": { + "name": "uq_security_findings_source", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "source", + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "security_findings_owner_check": { + "name": "security_findings_owner_check", + "value": "(\n (\"security_findings\".\"owned_by_user_id\" IS NOT NULL AND \"security_findings\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_findings\".\"owned_by_user_id\" IS NULL AND \"security_findings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.shared_cli_sessions": { + "name": "shared_cli_sessions", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_state": { + "name": "shared_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_shared_cli_sessions_session_id": { + "name": "IDX_shared_cli_sessions_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_shared_cli_sessions_created_at": { + "name": "IDX_shared_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_cli_sessions_session_id_cli_sessions_session_id_fk": { + "name": "shared_cli_sessions_session_id_cli_sessions_session_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_cli_sessions_shared_state_check": { + "name": "shared_cli_sessions_shared_state_check", + "value": "\"shared_cli_sessions\".\"shared_state\" IN ('public', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.slack_bot_requests": { + "name": "slack_bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_truncated": { + "name": "user_message_truncated", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_calls_made": { + "name": "tool_calls_made", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_slack_bot_requests_created_at": { + "name": "idx_slack_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_slack_team_id": { + "name": "idx_slack_bot_requests_slack_team_id", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_org_id": { + "name": "idx_slack_bot_requests_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_user_id": { + "name": "idx_slack_bot_requests_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_status": { + "name": "idx_slack_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_event_type": { + "name": "idx_slack_bot_requests_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_team_created": { + "name": "idx_slack_bot_requests_team_created", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_bot_requests_owned_by_organization_id_organizations_id_fk": { + "name": "slack_bot_requests_owned_by_organization_id_organizations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk": { + "name": "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "slack_bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_bot_requests_owner_check": { + "name": "slack_bot_requests_owner_check", + "value": "(\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NOT NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NOT NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.source_embeddings": { + "name": "source_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_line": { + "name": "start_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_line": { + "name": "end_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_base_branch": { + "name": "is_base_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_source_embeddings_organization_id": { + "name": "IDX_source_embeddings_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_kilo_user_id": { + "name": "IDX_source_embeddings_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_project_id": { + "name": "IDX_source_embeddings_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_created_at": { + "name": "IDX_source_embeddings_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_updated_at": { + "name": "IDX_source_embeddings_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_file_path_lower": { + "name": "IDX_source_embeddings_file_path_lower", + "columns": [ + { + "expression": "LOWER(\"file_path\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_git_branch": { + "name": "IDX_source_embeddings_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_org_project_branch": { + "name": "IDX_source_embeddings_org_project_branch", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_embeddings_organization_id_organizations_id_fk": { + "name": "source_embeddings_organization_id_organizations_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_embeddings_kilo_user_id_kilocode_users_id_fk": { + "name": "source_embeddings_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_source_embeddings_org_project_branch_file_lines": { + "name": "UQ_source_embeddings_org_project_branch_file_lines", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "project_id", + "git_branch", + "file_path", + "start_line", + "end_line" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stytch_fingerprints": { + "name": "stytch_fingerprints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_fingerprint": { + "name": "visitor_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_fingerprint": { + "name": "browser_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_id": { + "name": "browser_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hardware_fingerprint": { + "name": "hardware_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network_fingerprint": { + "name": "network_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict_action": { + "name": "verdict_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_device_type": { + "name": "detected_device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_authentic_device": { + "name": "is_authentic_device", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "reasons": { + "name": "reasons", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"\"}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fingerprint_data": { + "name": "fingerprint_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_free_tier_allowed": { + "name": "kilo_free_tier_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_fingerprint_data": { + "name": "idx_fingerprint_data", + "columns": [ + { + "expression": "fingerprint_data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_hardware_fingerprint": { + "name": "idx_hardware_fingerprint", + "columns": [ + { + "expression": "hardware_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id": { + "name": "idx_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stytch_fingerprints_reasons_gin": { + "name": "idx_stytch_fingerprints_reasons_gin", + "columns": [ + { + "expression": "reasons", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_verdict_action": { + "name": "idx_verdict_action", + "columns": [ + { + "expression": "verdict_action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_visitor_fingerprint": { + "name": "idx_visitor_fingerprint", + "columns": [ + { + "expression": "visitor_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompt_prefix": { + "name": "system_prompt_prefix", + "schema": "", + "columns": { + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_system_prompt_prefix": { + "name": "UQ_system_prompt_prefix", + "columns": [ + { + "expression": "system_prompt_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactional_email_log": { + "name": "transactional_email_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_transactional_email_log_type_idempotency_key": { + "name": "UQ_transactional_email_log_type_idempotency_key", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_transactional_email_log_user_id": { + "name": "IDX_transactional_email_log_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_email_log_user_id_kilocode_users_id_fk": { + "name": "transactional_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "transactional_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_admin_notes": { + "name": "user_admin_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note_content": { + "name": "note_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_kilo_user_id": { + "name": "admin_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_34517df0b385234babc38fe81b": { + "name": "IDX_34517df0b385234babc38fe81b", + "columns": [ + { + "expression": "admin_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_ccbde98c4c14046daa5682ec4f": { + "name": "IDX_ccbde98c4c14046daa5682ec4f", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_d0270eb24ef6442d65a0b7853c": { + "name": "IDX_d0270eb24ef6442d65a0b7853c", + "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.user_affiliate_attributions": { + "name": "user_affiliate_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_id": { + "name": "tracking_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_attributions_user_id": { + "name": "IDX_user_affiliate_attributions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_attributions_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_attributions_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_attributions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_attributions_user_provider": { + "name": "UQ_user_affiliate_attributions_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_attributions_provider_check": { + "name": "user_affiliate_attributions_provider_check", + "value": "\"user_affiliate_attributions\".\"provider\" IN ('impact')" + } + }, + "isRLSEnabled": false + }, + "public.user_affiliate_events": { + "name": "user_affiliate_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_event_id": { + "name": "parent_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_action_id": { + "name": "impact_action_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_submission_uri": { + "name": "impact_submission_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_events_claim_path": { + "name": "IDX_user_affiliate_events_claim_path", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_parent_event_id": { + "name": "IDX_user_affiliate_events_parent_event_id", + "columns": [ + { + "expression": "parent_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_provider_event_type_charge": { + "name": "IDX_user_affiliate_events_provider_event_type_charge", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_events_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_events_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_affiliate_events_parent_event_id_fk": { + "name": "user_affiliate_events_parent_event_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "user_affiliate_events", + "columnsFrom": [ + "parent_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_events_dedupe_key": { + "name": "UQ_user_affiliate_events_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_events_provider_check": { + "name": "user_affiliate_events_provider_check", + "value": "\"user_affiliate_events\".\"provider\" IN ('impact')" + }, + "user_affiliate_events_event_type_check": { + "name": "user_affiliate_events_event_type_check", + "value": "\"user_affiliate_events\".\"event_type\" IN ('signup', 'trial_start', 'trial_end', 'sale', 'sale_reversal')" + }, + "user_affiliate_events_delivery_state_check": { + "name": "user_affiliate_events_delivery_state_check", + "value": "\"user_affiliate_events\".\"delivery_state\" IN ('queued', 'blocked', 'sending', 'delivered', 'failed')" + }, + "user_affiliate_events_attempt_count_non_negative_check": { + "name": "user_affiliate_events_attempt_count_non_negative_check", + "value": "\"user_affiliate_events\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_auth_provider": { + "name": "user_auth_provider", + "schema": "", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_auth_provider_kilo_user_id": { + "name": "IDX_user_auth_provider_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_auth_provider_hosted_domain": { + "name": "IDX_user_auth_provider_hosted_domain", + "columns": [ + { + "expression": "hosted_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_auth_provider_provider_provider_account_id_pk": { + "name": "user_auth_provider_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_feedback": { + "name": "user_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback_for": { + "name": "feedback_for", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "feedback_batch": { + "name": "feedback_batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_feedback_created_at": { + "name": "IDX_user_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_kilo_user_id": { + "name": "IDX_user_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_for": { + "name": "IDX_user_feedback_feedback_for", + "columns": [ + { + "expression": "feedback_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_batch": { + "name": "IDX_user_feedback_feedback_batch", + "columns": [ + { + "expression": "feedback_batch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_source": { + "name": "IDX_user_feedback_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "user_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_period_cache": { + "name": "user_period_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cache_type": { + "name": "cache_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "shared_url_token": { + "name": "shared_url_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_user_period_cache_kilo_user_id": { + "name": "IDX_user_period_cache_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache": { + "name": "UQ_user_period_cache", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_period_cache_lookup": { + "name": "IDX_user_period_cache_lookup", + "columns": [ + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache_share_token": { + "name": "UQ_user_period_cache_share_token", + "columns": [ + { + "expression": "shared_url_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_period_cache\".\"shared_url_token\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_period_cache_kilo_user_id_kilocode_users_id_fk": { + "name": "user_period_cache_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_period_cache", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_period_cache_period_type_check": { + "name": "user_period_cache_period_type_check", + "value": "\"user_period_cache\".\"period_type\" IN ('year', 'quarter', 'month', 'week', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.user_push_tokens": { + "name": "user_push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_push_tokens_token": { + "name": "UQ_user_push_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_push_tokens_user_id": { + "name": "IDX_user_push_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_push_tokens_user_id_kilocode_users_id_fk": { + "name": "user_push_tokens_user_id_kilocode_users_id_fk", + "tableFrom": "user_push_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_city": { + "name": "vercel_ip_city", + "schema": "", + "columns": { + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_city": { + "name": "vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_city": { + "name": "UQ_vercel_ip_city", + "columns": [ + { + "expression": "vercel_ip_city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_country": { + "name": "vercel_ip_country", + "schema": "", + "columns": { + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_country": { + "name": "vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_country": { + "name": "UQ_vercel_ip_country", + "columns": [ + { + "expression": "vercel_ip_country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_action": { + "name": "event_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handlers_triggered": { + "name": "handlers_triggered", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event_signature": { + "name": "event_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_webhook_events_owned_by_org_id": { + "name": "IDX_webhook_events_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_owned_by_user_id": { + "name": "IDX_webhook_events_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_platform": { + "name": "IDX_webhook_events_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_event_type": { + "name": "IDX_webhook_events_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_created_at": { + "name": "IDX_webhook_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_owned_by_organization_id_organizations_id_fk": { + "name": "webhook_events_owned_by_organization_id_organizations_id_fk", + "tableFrom": "webhook_events", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_events_owned_by_user_id_kilocode_users_id_fk": { + "name": "webhook_events_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "webhook_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_webhook_events_signature": { + "name": "UQ_webhook_events_signature", + "nullsNotDistinct": false, + "columns": [ + "event_signature" + ] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_events_owner_check": { + "name": "webhook_events_owner_check", + "value": "(\n (\"webhook_events\".\"owned_by_user_id\" IS NOT NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NULL) OR\n (\"webhook_events\".\"owned_by_user_id\" IS NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.microdollar_usage_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "definition": "\n SELECT\n mu.id,\n mu.kilo_user_id,\n meta.message_id,\n mu.cost,\n mu.input_tokens,\n mu.output_tokens,\n mu.cache_write_tokens,\n mu.cache_hit_tokens,\n mu.created_at,\n ip.http_ip AS http_x_forwarded_for,\n city.vercel_ip_city AS http_x_vercel_ip_city,\n country.vercel_ip_country AS http_x_vercel_ip_country,\n meta.vercel_ip_latitude AS http_x_vercel_ip_latitude,\n meta.vercel_ip_longitude AS http_x_vercel_ip_longitude,\n ja4.ja4_digest AS http_x_vercel_ja4_digest,\n mu.provider,\n mu.model,\n mu.requested_model,\n meta.user_prompt_prefix,\n spp.system_prompt_prefix,\n meta.system_prompt_length,\n ua.http_user_agent,\n mu.cache_discount,\n meta.max_tokens,\n meta.has_middle_out_transform,\n mu.has_error,\n mu.abuse_classification,\n mu.organization_id,\n mu.inference_provider,\n mu.project_id,\n meta.status_code,\n meta.upstream_id,\n frfr.finish_reason,\n meta.latency,\n meta.moderation_latency,\n meta.generation_time,\n meta.is_byok,\n meta.is_user_byok,\n meta.streamed,\n meta.cancelled,\n edit.editor_name,\n ak.api_kind,\n meta.has_tools,\n meta.machine_id,\n feat.feature,\n meta.session_id,\n md.mode,\n am.auto_model,\n meta.market_cost,\n meta.is_free\n FROM \"microdollar_usage\" mu\n LEFT JOIN \"microdollar_usage_metadata\" meta ON mu.id = meta.id\n LEFT JOIN \"http_ip\" ip ON meta.http_ip_id = ip.http_ip_id\n LEFT JOIN \"vercel_ip_city\" city ON meta.vercel_ip_city_id = city.vercel_ip_city_id\n LEFT JOIN \"vercel_ip_country\" country ON meta.vercel_ip_country_id = country.vercel_ip_country_id\n LEFT JOIN \"ja4_digest\" ja4 ON meta.ja4_digest_id = ja4.ja4_digest_id\n LEFT JOIN \"system_prompt_prefix\" spp ON meta.system_prompt_prefix_id = spp.system_prompt_prefix_id\n LEFT JOIN \"http_user_agent\" ua ON meta.http_user_agent_id = ua.http_user_agent_id\n LEFT JOIN \"finish_reason\" frfr ON meta.finish_reason_id = frfr.finish_reason_id\n LEFT JOIN \"editor_name\" edit ON meta.editor_name_id = edit.editor_name_id\n LEFT JOIN \"api_kind\" ak ON meta.api_kind_id = ak.api_kind_id\n LEFT JOIN \"feature\" feat ON meta.feature_id = feat.feature_id\n LEFT JOIN \"mode\" md ON meta.mode_id = md.mode_id\n LEFT JOIN \"auto_model\" am ON meta.auto_model_id = am.auto_model_id\n", + "name": "microdollar_usage_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c06a4dba12..89c401949e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -834,6 +834,13 @@ "when": 1778169599348, "tag": "0118_blue_rawhide_kid", "breakpoints": true + }, + { + "idx": 119, + "version": "7", + "when": 1778177693899, + "tag": "0119_sad_katie_power", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index f374c63bce..ada0bdcfcd 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -225,6 +225,101 @@ export const AffiliateEventDeliveryState = { export type AffiliateEventDeliveryState = (typeof AffiliateEventDeliveryState)[keyof typeof AffiliateEventDeliveryState]; +export const KiloClawAttributionTouchType = { + Affiliate: 'affiliate', + Referral: 'referral', +} as const; + +export type KiloClawAttributionTouchType = + (typeof KiloClawAttributionTouchType)[keyof typeof KiloClawAttributionTouchType]; + +export const KiloClawAttributionTouchProvider = { + ImpactPerformance: 'impact_performance', + ImpactAdvocate: 'impact_advocate', +} as const; + +export type KiloClawAttributionTouchProvider = + (typeof KiloClawAttributionTouchProvider)[keyof typeof KiloClawAttributionTouchProvider]; + +export const ImpactAdvocateRegistrationState = { + Pending: 'pending', + Retrying: 'retrying', + Registered: 'registered', + Failed: 'failed', +} as const; + +export type ImpactAdvocateRegistrationState = + (typeof ImpactAdvocateRegistrationState)[keyof typeof ImpactAdvocateRegistrationState]; + +export const ImpactAdvocateAttemptDeliveryState = { + Queued: 'queued', + Sending: 'sending', + Succeeded: 'succeeded', + Failed: 'failed', +} as const; + +export type ImpactAdvocateAttemptDeliveryState = + (typeof ImpactAdvocateAttemptDeliveryState)[keyof typeof ImpactAdvocateAttemptDeliveryState]; + +export const KiloClawReferralBeneficiaryRole = { + Referrer: 'referrer', + Referee: 'referee', +} as const; + +export type KiloClawReferralBeneficiaryRole = + (typeof KiloClawReferralBeneficiaryRole)[keyof typeof KiloClawReferralBeneficiaryRole]; + +export const KiloClawReferralWinningTouchType = { + Referral: 'referral', + Affiliate: 'affiliate', + None: 'none', +} as const; + +export type KiloClawReferralWinningTouchType = + (typeof KiloClawReferralWinningTouchType)[keyof typeof KiloClawReferralWinningTouchType]; + +export const KiloClawReferralDecisionOutcome = { + Granted: 'granted', + CapLimited: 'cap_limited', + Disqualified: 'disqualified', +} as const; + +export type KiloClawReferralDecisionOutcome = + (typeof KiloClawReferralDecisionOutcome)[keyof typeof KiloClawReferralDecisionOutcome]; + +export const KiloClawReferralRewardStatus = { + Pending: 'pending', + Earned: 'earned', + Applied: 'applied', + Reversed: 'reversed', + Expired: 'expired', + Canceled: 'canceled', + ReviewRequired: 'review_required', +} as const; + +export type KiloClawReferralRewardStatus = + (typeof KiloClawReferralRewardStatus)[keyof typeof KiloClawReferralRewardStatus]; + +export const ImpactConversionReportState = { + Queued: 'queued', + Retrying: 'retrying', + Delivered: 'delivered', + Failed: 'failed', +} as const; + +export type ImpactConversionReportState = + (typeof ImpactConversionReportState)[keyof typeof ImpactConversionReportState]; + +export const ImpactAdvocateRewardRedemptionState = { + Queued: 'queued', + Retrying: 'retrying', + Redeemed: 'redeemed', + Failed: 'failed', +} as const; + +export type ImpactAdvocateRewardRedemptionState = + (typeof ImpactAdvocateRewardRedemptionState)[keyof typeof ImpactAdvocateRewardRedemptionState]; + // NOTE: Do not change these action names. Use present tense for consistency. export const KiloClawAdminAuditAction = z.enum([ 'kiloclaw.volume.extend', diff --git a/packages/db/src/schema.test.ts b/packages/db/src/schema.test.ts index 860645307e..8d35b34ba6 100644 --- a/packages/db/src/schema.test.ts +++ b/packages/db/src/schema.test.ts @@ -122,6 +122,24 @@ describe('database schema', () => { AffiliateProvider: ['impact'], AffiliateEventType: ['signup', 'trial_start', 'trial_end', 'sale', 'sale_reversal'], AffiliateEventDeliveryState: ['queued', 'blocked', 'sending', 'delivered', 'failed'], + KiloClawAttributionTouchType: ['affiliate', 'referral'], + KiloClawAttributionTouchProvider: ['impact_advocate', 'impact_performance'], + ImpactAdvocateRegistrationState: ['pending', 'retrying', 'registered', 'failed'], + ImpactAdvocateAttemptDeliveryState: ['queued', 'sending', 'succeeded', 'failed'], + KiloClawReferralBeneficiaryRole: ['referrer', 'referee'], + KiloClawReferralWinningTouchType: ['referral', 'affiliate', 'none'], + KiloClawReferralDecisionOutcome: ['granted', 'cap_limited', 'disqualified'], + KiloClawReferralRewardStatus: [ + 'pending', + 'earned', + 'applied', + 'reversed', + 'expired', + 'canceled', + 'review_required', + ], + ImpactConversionReportState: ['queued', 'retrying', 'delivered', 'failed'], + ImpactAdvocateRewardRedemptionState: ['queued', 'retrying', 'redeemed', 'failed'], }; const actualEnumValues: Record = {}; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 55f1199b68..54c0991187 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -49,6 +49,16 @@ import { AffiliateProvider, AffiliateEventType, AffiliateEventDeliveryState, + KiloClawAttributionTouchType, + KiloClawAttributionTouchProvider, + ImpactAdvocateRegistrationState, + ImpactAdvocateAttemptDeliveryState, + KiloClawReferralBeneficiaryRole, + KiloClawReferralWinningTouchType, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + ImpactConversionReportState, + ImpactAdvocateRewardRedemptionState, } from './schema-types'; import type { CustomLlmDefinition, @@ -132,6 +142,16 @@ export const SCHEMA_CHECK_ENUMS = { AffiliateProvider, AffiliateEventType, AffiliateEventDeliveryState, + KiloClawAttributionTouchType, + KiloClawAttributionTouchProvider, + ImpactAdvocateRegistrationState, + ImpactAdvocateAttemptDeliveryState, + KiloClawReferralBeneficiaryRole, + KiloClawReferralWinningTouchType, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + ImpactConversionReportState, + ImpactAdvocateRewardRedemptionState, } as const; export type AffiliateEventPayloadJson = { @@ -433,6 +453,485 @@ export const pending_impact_sale_reversals = pgTable( export type PendingImpactSaleReversal = typeof pending_impact_sale_reversals.$inferSelect; +export const deleted_user_email_tombstones = pgTable('deleted_user_email_tombstones', { + normalized_email_hash: text().primaryKey().notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + +export type DeletedUserEmailTombstone = typeof deleted_user_email_tombstones.$inferSelect; + +export const kiloclaw_attribution_touches = pgTable( + 'kiloclaw_attribution_touches', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + dedupe_key: text().notNull(), + anonymous_id: text(), + user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + touch_type: text().notNull().$type(), + provider: text().notNull().$type(), + opaque_tracking_value: text(), + tracking_value_length: integer().notNull(), + is_tracking_value_accepted: boolean().notNull().default(true), + rs_code: text(), + rs_share_medium: text(), + rs_engagement_medium: text(), + im_ref: text(), + landing_path: text(), + utm_source: text(), + utm_medium: text(), + utm_campaign: text(), + utm_term: text(), + utm_content: text(), + touched_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + expires_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + sale_attributed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_attribution_touches_dedupe_key').on(table.dedupe_key), + index('IDX_kiloclaw_attribution_touches_user_id').on(table.user_id), + index('IDX_kiloclaw_attribution_touches_anonymous_id').on(table.anonymous_id), + index('IDX_kiloclaw_attribution_touches_expires_at').on(table.expires_at), + index('IDX_kiloclaw_attribution_touches_sale_attributed_at').on(table.sale_attributed_at), + enumCheck( + 'kiloclaw_attribution_touches_touch_type_check', + table.touch_type, + KiloClawAttributionTouchType + ), + enumCheck( + 'kiloclaw_attribution_touches_provider_check', + table.provider, + KiloClawAttributionTouchProvider + ), + check( + 'kiloclaw_attribution_touches_tracking_value_length_non_negative_check', + sql`${table.tracking_value_length} >= 0` + ), + ] +); + +export type KiloClawAttributionTouch = typeof kiloclaw_attribution_touches.$inferSelect; + +export const impact_advocate_participants = pgTable( + 'impact_advocate_participants', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + advocate_id: text().notNull(), + advocate_account_id: text().notNull(), + opaque_referral_identifier: text(), + contact_email: text(), + locale: text(), + country_code: text(), + registration_state: text() + .notNull() + .$type() + .default(ImpactAdvocateRegistrationState.Pending), + registered_at: timestamp({ withTimezone: true, mode: 'string' }), + last_registration_attempt_at: timestamp({ withTimezone: true, mode: 'string' }), + last_error_code: text(), + last_error_message: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_advocate_participants_user_id').on(table.user_id), + unique('UQ_impact_advocate_participants_opaque_referral_identifier').on( + table.opaque_referral_identifier + ), + index('IDX_impact_advocate_participants_registration_state').on(table.registration_state), + enumCheck( + 'impact_advocate_participants_registration_state_check', + table.registration_state, + ImpactAdvocateRegistrationState + ), + ] +); + +export type ImpactAdvocateParticipant = typeof impact_advocate_participants.$inferSelect; + +export const impact_advocate_registration_attempts = pgTable( + 'impact_advocate_registration_attempts', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + participant_id: uuid() + .notNull() + .references(() => impact_advocate_participants.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + dedupe_key: text().notNull(), + opaque_cookie_value: text(), + cookie_value_length: integer().notNull(), + delivery_state: text() + .notNull() + .$type() + .default(ImpactAdvocateAttemptDeliveryState.Queued), + request_payload: jsonb().$type | null>(), + response_payload: jsonb().$type | null>(), + response_status_code: integer(), + attempt_count: integer().notNull().default(0), + next_retry_at: timestamp({ withTimezone: true, mode: 'string' }), + claimed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_advocate_registration_attempts_dedupe_key').on(table.dedupe_key), + index('IDX_impact_advocate_registration_attempts_participant_id').on(table.participant_id), + index('IDX_impact_advocate_registration_attempts_delivery_state').on(table.delivery_state), + enumCheck( + 'impact_advocate_registration_attempts_delivery_state_check', + table.delivery_state, + ImpactAdvocateAttemptDeliveryState + ), + check( + 'impact_advocate_registration_attempts_cookie_value_length_non_negative_check', + sql`${table.cookie_value_length} >= 0` + ), + check( + 'impact_advocate_registration_attempts_attempt_count_non_negative_check', + sql`${table.attempt_count} >= 0` + ), + ] +); + +export type ImpactAdvocateRegistrationAttempt = + typeof impact_advocate_registration_attempts.$inferSelect; + +export const kiloclaw_referrals = pgTable( + 'kiloclaw_referrals', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + referee_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + referrer_user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + source_touch_id: uuid().references(() => kiloclaw_attribution_touches.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + impact_referral_id: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referrals_referee_user_id').on(table.referee_user_id), + index('IDX_kiloclaw_referrals_referrer_user_id').on(table.referrer_user_id), + index('IDX_kiloclaw_referrals_source_touch_id').on(table.source_touch_id), + ] +); + +export type KiloClawReferral = typeof kiloclaw_referrals.$inferSelect; + +export const kiloclaw_referral_conversions = pgTable( + 'kiloclaw_referral_conversions', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + referee_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + referrer_user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + source_touch_id: uuid().references(() => kiloclaw_attribution_touches.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + winning_touch_type: text().notNull().$type(), + source_payment_id: text().notNull(), + qualified: boolean().notNull().default(false), + disqualification_reason: text(), + converted_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referral_conversions_source_payment_id').on(table.source_payment_id), + index('IDX_kiloclaw_referral_conversions_referee_user_id').on(table.referee_user_id), + index('IDX_kiloclaw_referral_conversions_referrer_user_id').on(table.referrer_user_id), + enumCheck( + 'kiloclaw_referral_conversions_winning_touch_type_check', + table.winning_touch_type, + KiloClawReferralWinningTouchType + ), + ] +); + +export type KiloClawReferralConversion = typeof kiloclaw_referral_conversions.$inferSelect; + +export const kiloclaw_referral_reward_decisions = pgTable( + 'kiloclaw_referral_reward_decisions', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + conversion_id: uuid() + .notNull() + .references(() => kiloclaw_referral_conversions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + beneficiary_role: text().notNull().$type(), + outcome: text().notNull().$type(), + reason: text(), + months_granted: integer().notNull().default(0), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referral_reward_decisions_conversion_role').on( + table.conversion_id, + table.beneficiary_role + ), + index('IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id').on( + table.beneficiary_user_id + ), + enumCheck( + 'kiloclaw_referral_reward_decisions_beneficiary_role_check', + table.beneficiary_role, + KiloClawReferralBeneficiaryRole + ), + enumCheck( + 'kiloclaw_referral_reward_decisions_outcome_check', + table.outcome, + KiloClawReferralDecisionOutcome + ), + check( + 'kiloclaw_referral_reward_decisions_months_granted_non_negative_check', + sql`${table.months_granted} >= 0` + ), + ] +); + +export type KiloClawReferralRewardDecision = typeof kiloclaw_referral_reward_decisions.$inferSelect; + +export const kiloclaw_referral_rewards = pgTable( + 'kiloclaw_referral_rewards', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + conversion_id: uuid() + .notNull() + .references(() => kiloclaw_referral_conversions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + decision_id: uuid() + .notNull() + .references(() => kiloclaw_referral_reward_decisions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + beneficiary_role: text().notNull().$type(), + months_granted: integer().notNull().default(1), + status: text() + .notNull() + .$type() + .default(KiloClawReferralRewardStatus.Pending), + applies_to_subscription_id: uuid(), + earned_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + applied_at: timestamp({ withTimezone: true, mode: 'string' }), + reversed_at: timestamp({ withTimezone: true, mode: 'string' }), + expires_at: timestamp({ withTimezone: true, mode: 'string' }), + review_reason: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referral_rewards_conversion_role').on( + table.conversion_id, + table.beneficiary_role + ), + unique('UQ_kiloclaw_referral_rewards_decision_id').on(table.decision_id), + index('IDX_kiloclaw_referral_rewards_beneficiary_user_id').on(table.beneficiary_user_id), + index('IDX_kiloclaw_referral_rewards_status').on(table.status), + enumCheck( + 'kiloclaw_referral_rewards_beneficiary_role_check', + table.beneficiary_role, + KiloClawReferralBeneficiaryRole + ), + enumCheck('kiloclaw_referral_rewards_status_check', table.status, KiloClawReferralRewardStatus), + check( + 'kiloclaw_referral_rewards_months_granted_positive_check', + sql`${table.months_granted} > 0` + ), + ] +); + +export type KiloClawReferralReward = typeof kiloclaw_referral_rewards.$inferSelect; + +export const kiloclaw_referral_reward_applications = pgTable( + 'kiloclaw_referral_reward_applications', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + reward_id: uuid() + .notNull() + .references(() => kiloclaw_referral_rewards.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + subscription_id: uuid(), + previous_renewal_boundary: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + new_renewal_boundary: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + local_operation_id: text(), + stripe_operation_id: text(), + stripe_idempotency_key: text(), + applied_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + index('IDX_kiloclaw_referral_reward_applications_reward_id').on(table.reward_id), + index('IDX_kiloclaw_referral_reward_applications_beneficiary_user_id').on( + table.beneficiary_user_id + ), + ] +); + +export type KiloClawReferralRewardApplication = + typeof kiloclaw_referral_reward_applications.$inferSelect; + +export const impact_advocate_reward_redemptions = pgTable( + 'impact_advocate_reward_redemptions', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + reward_id: uuid() + .notNull() + .references(() => kiloclaw_referral_rewards.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + dedupe_key: text().notNull(), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + state: text() + .notNull() + .$type() + .default(ImpactAdvocateRewardRedemptionState.Queued), + impact_reward_id: text(), + request_payload: jsonb().$type | null>(), + lookup_response_payload: jsonb().$type | null>(), + redeem_response_payload: jsonb().$type | null>(), + response_status_code: integer(), + attempt_count: integer().notNull().default(0), + next_retry_at: timestamp({ withTimezone: true, mode: 'string' }), + redeemed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_advocate_reward_redemptions_reward_id').on(table.reward_id), + unique('UQ_impact_advocate_reward_redemptions_dedupe_key').on(table.dedupe_key), + index('IDX_impact_advocate_reward_redemptions_beneficiary_user_id').on( + table.beneficiary_user_id + ), + index('IDX_impact_advocate_reward_redemptions_state').on(table.state), + enumCheck( + 'impact_advocate_reward_redemptions_state_check', + table.state, + ImpactAdvocateRewardRedemptionState + ), + check( + 'impact_advocate_reward_redemptions_attempt_count_non_negative_check', + sql`${table.attempt_count} >= 0` + ), + ] +); + +export type ImpactAdvocateRewardRedemption = typeof impact_advocate_reward_redemptions.$inferSelect; + +export const impact_conversion_reports = pgTable( + 'impact_conversion_reports', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + conversion_id: uuid().references(() => kiloclaw_referral_conversions.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + dedupe_key: text().notNull(), + action_tracker_id: integer().notNull(), + order_id: text().notNull(), + state: text() + .notNull() + .$type() + .default(ImpactConversionReportState.Queued), + request_payload: jsonb().$type | null>(), + response_payload: jsonb().$type | null>(), + response_status_code: integer(), + attempt_count: integer().notNull().default(0), + next_retry_at: timestamp({ withTimezone: true, mode: 'string' }), + delivered_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_conversion_reports_dedupe_key').on(table.dedupe_key), + index('IDX_impact_conversion_reports_conversion_id').on(table.conversion_id), + index('IDX_impact_conversion_reports_state').on(table.state), + enumCheck('impact_conversion_reports_state_check', table.state, ImpactConversionReportState), + check( + 'impact_conversion_reports_attempt_count_non_negative_check', + sql`${table.attempt_count} >= 0` + ), + ] +); + +export type ImpactConversionReport = typeof impact_conversion_reports.$inferSelect; + export const kilo_pass_subscriptions = pgTable( 'kilo_pass_subscriptions', { diff --git a/scripts/verify-drizzle-bootstrap.sh b/scripts/verify-drizzle-bootstrap.sh new file mode 100755 index 0000000000..f5034d2340 --- /dev/null +++ b/scripts/verify-drizzle-bootstrap.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +docker compose -f dev/docker-compose.yml up -d --wait postgres >/dev/null + +BASE_POSTGRES_URL="$({ + if [ -n "${POSTGRES_URL:-}" ]; then + printf '%s' "$POSTGRES_URL" + else + node <<'NODE' +const fs = require('fs'); + +for (const path of ['.env.local', '.env']) { + if (!fs.existsSync(path)) continue; + + for (const line of fs.readFileSync(path, 'utf8').split(/\r?\n/)) { + if (!line.startsWith('POSTGRES_URL=')) continue; + + let value = line.slice('POSTGRES_URL='.length).trim(); + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + + process.stdout.write(value); + process.exit(0); + } +} + +console.error('POSTGRES_URL is not set in the environment or .env.local'); +process.exit(1); +NODE + fi +})" + +TEMP_DB="drizzle_bootstrap_$(date +%s)_${RANDOM}" +TEMP_POSTGRES_URL="$(node -e "const u = new URL(process.argv[1]); u.pathname = '/${TEMP_DB}'; process.stdout.write(u.toString());" "$BASE_POSTGRES_URL")" + +cleanup() { + docker compose -f dev/docker-compose.yml exec -T postgres \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c "DROP DATABASE IF EXISTS \"${TEMP_DB}\" WITH (FORCE);" >/dev/null +} +trap cleanup EXIT + +docker compose -f dev/docker-compose.yml exec -T postgres \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE \"${TEMP_DB}\";" >/dev/null + +POSTGRES_URL="$TEMP_POSTGRES_URL" pnpm drizzle migrate + +echo "Verified pnpm drizzle migrate against empty database: ${TEMP_DB}" diff --git a/services/kiloclaw-billing/src/lifecycle.test.ts b/services/kiloclaw-billing/src/lifecycle.test.ts index eac2ba0013..71a501bda7 100644 --- a/services/kiloclaw-billing/src/lifecycle.test.ts +++ b/services/kiloclaw-billing/src/lifecycle.test.ts @@ -1140,11 +1140,19 @@ describe('credit renewal sweep affiliate tracking', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - case 'enqueue_affiliate_event': - return new Response(JSON.stringify({ enqueued: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); + case 'process_paid_conversion': + return new Response( + JSON.stringify({ + affiliateSaleEnqueued: true, + winningTouchType: 'affiliate', + conversionId: null, + disqualificationReason: null, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ); default: throw new Error(`Unexpected side effect action: ${body.action}`); } @@ -1195,14 +1203,12 @@ describe('credit renewal sweep affiliate tracking', () => { input: Record; } ) - .find(call => call.action === 'enqueue_affiliate_event'); + .find(call => call.action === 'process_paid_conversion'); expect(saleCall).toEqual({ - action: 'enqueue_affiliate_event', + action: 'process_paid_conversion', input: { userId: 'user-1', - provider: 'impact', - eventType: 'sale', dedupeKey: 'affiliate:impact:sale:kiloclaw-subscription:instance-1:2026-04', eventDateIso: renewalAt, orderId: 'kiloclaw-subscription:instance-1:2026-04', @@ -1215,6 +1221,86 @@ describe('credit renewal sweep affiliate tracking', () => { }); }); + it('does not roll back or fail renewal when paid conversion side effect fails', async () => { + const renewalAt = '2026-04-09T10:00:00.000Z'; + const { db, txUpdates } = createMockDb([ + [ + { + user_id: 'user-1', + email: 'user-1@example.com', + instance_id: 'instance-1', + id: 'sub-1', + instance_row_id: 'instance-1', + organization_id: null, + instance_destroyed_at: null, + plan: 'standard', + status: 'active', + credit_renewal_at: renewalAt, + current_period_end: renewalAt, + cancel_at_period_end: false, + scheduled_plan: null, + commit_ends_at: null, + past_due_since: null, + suspended_at: null, + auto_resume_attempt_count: 0, + auto_top_up_triggered_for_period: null, + total_microdollars_acquired: 50_000_000, + microdollars_used: 0, + auto_top_up_enabled: false, + kilo_pass_threshold: null, + next_credit_expiration_at: null, + user_updated_at: '2026-04-09T09:00:00.000Z', + }, + ], + ]); + mockGetWorkerDb.mockReturnValue(db); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + vi.fn(async (_request: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { + action: string; + }; + + switch (body.action) { + case 'project_pending_kilo_pass_bonus': + return new Response(JSON.stringify({ projectedBonusMicrodollars: 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + case 'issue_kilo_pass_bonus_from_usage_threshold': + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + case 'process_paid_conversion': + return new Response(JSON.stringify({ error: 'temporarily unavailable' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + default: + throw new Error(`Unexpected side effect action: ${body.action}`); + } + }) + ); + + const summary = await runSweep( + createEnv(vi.fn()), + { + runId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + sweep: 'credit_renewal', + }, + 1 + ); + + expect(summary.credit_renewals).toBe(1); + expect(summary.errors).toBe(0); + expect(txUpdates).toEqual( + expect.arrayContaining([expect.objectContaining({ current_period_start: renewalAt })]) + ); + expect(txUpdates.some(update => 'microdollars_used' in update)).toBe(true); + expect(txUpdates).not.toContainEqual(expect.objectContaining({ credit_renewal_at: renewalAt })); + }); + it('re-enqueues the existing sale dedupe key when the renewal deduction already committed', async () => { const renewalAt = '2026-04-09T10:00:00.000Z'; const { db, txInserts, txUpdates } = createMockDb( @@ -1264,11 +1350,19 @@ describe('credit renewal sweep affiliate tracking', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - case 'enqueue_affiliate_event': - return new Response(JSON.stringify({ enqueued: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); + case 'process_paid_conversion': + return new Response( + JSON.stringify({ + affiliateSaleEnqueued: true, + winningTouchType: 'affiliate', + conversionId: null, + disqualificationReason: null, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ); default: throw new Error(`Unexpected side effect action: ${body.action}`); } @@ -1308,11 +1402,9 @@ describe('credit renewal sweep affiliate tracking', () => { }, }, { - action: 'enqueue_affiliate_event', + action: 'process_paid_conversion', input: { userId: 'user-1', - provider: 'impact', - eventType: 'sale', dedupeKey: 'affiliate:impact:sale:kiloclaw-subscription:instance-1:2026-04', eventDateIso: renewalAt, orderId: 'kiloclaw-subscription:instance-1:2026-04', diff --git a/services/kiloclaw-billing/src/lifecycle.ts b/services/kiloclaw-billing/src/lifecycle.ts index f2e53f931a..7a8a02accb 100644 --- a/services/kiloclaw-billing/src/lifecycle.ts +++ b/services/kiloclaw-billing/src/lifecycle.ts @@ -211,6 +211,20 @@ type SideEffectRequest = itemName?: string; }; } + | { + action: 'process_paid_conversion'; + input: { + userId: string; + dedupeKey: string; + eventDateIso: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; + }; + } | { action: 'project_pending_kilo_pass_bonus'; input: { @@ -232,9 +246,16 @@ type SideEffectResponse = T['action'] extends 'send ? { repaired: boolean } : T['action'] extends 'enqueue_affiliate_event' ? { enqueued: boolean } - : T['action'] extends 'project_pending_kilo_pass_bonus' - ? { projectedBonusMicrodollars: number } - : { ok: true }; + : T['action'] extends 'process_paid_conversion' + ? { + affiliateSaleEnqueued: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; + } + : T['action'] extends 'project_pending_kilo_pass_bonus' + ? { projectedBonusMicrodollars: number } + : { ok: true }; export class KiloClawApiError extends Error { readonly statusCode: number; @@ -1004,6 +1025,50 @@ async function enqueueAffiliateEvent( ); } +type PaidConversionParams = { + userId: string; + dedupeKey: string; + eventDateIso: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; +}; + +async function processPaidConversion( + env: BillingWorkerEnv, + context: SweepExecutionContext, + params: PaidConversionParams +): Promise { + await callBillingSideEffect( + env, + context, + { + action: 'process_paid_conversion', + input: params, + }, + { userId: params.userId } + ); +} + +async function processPaidConversionBestEffort( + env: BillingWorkerEnv, + context: SweepExecutionContext, + params: PaidConversionParams +): Promise { + try { + await processPaidConversion(env, context, params); + } catch (error) { + log('error', 'Paid conversion side effect failed after credit renewal', { + userId: params.userId, + dedupeKey: params.dedupeKey, + error: error instanceof Error ? error.message : String(error), + }); + } +} + async function autoResumeIfSuspended( env: BillingWorkerEnv, database: WorkerDb, @@ -1302,10 +1367,8 @@ async function processCreditRenewalRow( }); if (!deductionIsNew) { - await enqueueAffiliateEvent(env, context, { + await processPaidConversionBestEffort(env, context, { userId, - provider: 'impact', - eventType: 'sale', dedupeKey: `affiliate:impact:sale:${deductionCategory}`, eventDateIso: renewalAt, orderId: deductionCategory, @@ -1314,13 +1377,13 @@ async function processCreditRenewalRow( itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), itemName: getKiloClawAffiliateItemName(effectivePlan), itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), - }).catch(error => { - log('warn', 'Affiliate sale enqueue recovery failed during duplicate credit renewal', { - userId, - error: error instanceof Error ? error.message : String(error), - }); }); + await database + .update(kiloclaw_subscriptions) + .set({ credit_renewal_at: newPeriodEnd }) + .where(eq(kiloclaw_subscriptions.id, row.id)); + summary.credit_renewals_skipped_duplicate++; return; } @@ -1357,10 +1420,8 @@ async function processCreditRenewalRow( }); } - await enqueueAffiliateEvent(env, context, { + await processPaidConversionBestEffort(env, context, { userId, - provider: 'impact', - eventType: 'sale', dedupeKey: `affiliate:impact:sale:${deductionCategory}`, eventDateIso: renewalAt, orderId: deductionCategory, @@ -1369,11 +1430,6 @@ async function processCreditRenewalRow( itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), itemName: getKiloClawAffiliateItemName(effectivePlan), itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), - }).catch(error => { - log('warn', 'Affiliate sale enqueue failed during credit renewal', { - userId, - error: error instanceof Error ? error.message : String(error), - }); }); summary.credit_renewals++; diff --git a/services/kiloclaw/DEVELOPMENT.md b/services/kiloclaw/DEVELOPMENT.md index 7d4e9d52c7..73c3f03b15 100644 --- a/services/kiloclaw/DEVELOPMENT.md +++ b/services/kiloclaw/DEVELOPMENT.md @@ -143,9 +143,41 @@ don't need to manage this manually. - **Free quick tunnel** (default): hostname changes on every restart. The script handles this automatically. -- **Named tunnel**: preconfigure in the Cloudflare dashboard for a persistent - hostname (e.g., `yourname.devclaw.dev`). Use `--tunnel-name ` or set - `TUNNEL_NAME` and `TUNNEL_HOSTNAME` in your config file. +- **Named tunnel**: preconfigure Cloudflare Tunnel/DNS for persistent + hostnames, then set `TUNNEL_NAME` or `TUNNEL_CONFIG` in your dev-start config + file. + +For a full local stack over HTTPS, prefer separate named-tunnel hostnames: + +```conf +# ~/.config/kiloclaw/dev-start.conf or services/kiloclaw/scripts/.dev-start.conf +TUNNEL_CONFIG=~/.cloudflared/accounts/kilo-local-dev.yml +TUNNEL_APP_HOSTNAME=app-dev.yourdomain.com +TUNNEL_KILOCLAW_HOSTNAME=claw-dev.yourdomain.com +TUNNEL_KILOCHAT_HOSTNAME=chat-dev.yourdomain.com +``` + +with cloudflared ingress similar to: + +```yaml +ingress: + - hostname: app-dev.yourdomain.com + service: http://localhost:3000 + - hostname: claw-dev.yourdomain.com + service: http://localhost:8795 + - hostname: chat-dev.yourdomain.com + service: http://localhost:8808 + - service: http_status:404 +``` + +When named tunnel hostnames are configured, `dev:start` writes: + +- `services/kiloclaw/.dev.vars`: `BACKEND_API_URL`, `KILOCODE_API_BASE_URL`, + `KILOCLAW_CHECKIN_URL`, `KILOCHAT_BASE_URL`, and appends the tunnel origins to + `OPENCLAW_ALLOWED_ORIGINS`. +- `.env.local`: `APP_URL_OVERRIDE`, `NEXTAUTH_URL`, and `KILOCLAW_API_URL`. + +Set `TUNNEL_UPDATE_APP_ENV=false` to leave `.env.local` untouched. ### If the tunnel isn't working diff --git a/services/kiloclaw/README.md b/services/kiloclaw/README.md index c7732e4c04..a8b2ced653 100644 --- a/services/kiloclaw/README.md +++ b/services/kiloclaw/README.md @@ -116,6 +116,12 @@ pnpm start # wrangler dev New provisions without an explicit provider use `KILOCLAW_DEFAULT_PROVIDER`. You can also pass `provider: "fly"` to the platform provision endpoint if you need Fly for a specific test. +To run the full local stack over stable Cloudflare Tunnel hostnames, set +`TUNNEL_CONFIG`, `TUNNEL_APP_HOSTNAME`, `TUNNEL_KILOCLAW_HOSTNAME`, and +optionally `TUNNEL_KILOCHAT_HOSTNAME` in `services/kiloclaw/scripts/.dev-start.conf` +or `~/.config/kiloclaw/dev-start.conf`, then run `pnpm dev:start kiloclaw`. See +`DEVELOPMENT.md` for the ingress example and generated env vars. + Rebuild the image after controller or Dockerfile changes, then restart or redeploy the instance so the container is recreated with the new image/env/config. A plain `start` leaves an already-running docker-local container intact. ## Fly Provider diff --git a/services/kiloclaw/scripts/.dev-start.conf.example b/services/kiloclaw/scripts/.dev-start.conf.example index ae8e75e549..c6e1331441 100644 --- a/services/kiloclaw/scripts/.dev-start.conf.example +++ b/services/kiloclaw/scripts/.dev-start.conf.example @@ -5,9 +5,24 @@ # ~/.config/kiloclaw/dev-start.conf (shared across all worktrees) # kiloclaw/scripts/.dev-start.conf (per-worktree override, gitignored) -# Named Cloudflare tunnel (leave empty for temporary quick tunnel) +# Named Cloudflare tunnel (leave empty for temporary quick tunnel). +# Use TUNNEL_CONFIG when credentials live outside ~/.cloudflared or when a +# config file defines multiple ingress hostnames. # TUNNEL_NAME= # tunnel +# TUNNEL_CONFIG= # ~/.cloudflared/accounts/kilo-local-dev.yml +# +# Single-hostname legacy mode. Requires cloudflared ingress path routing if +# the same hostname serves the app, KiloClaw worker, and Kilo Chat. # TUNNEL_HOSTNAME= # tunnel.yourdomain.com +# +# Multi-hostname mode for running the full local stack over a named tunnel. +# TUNNEL_APP_HOSTNAME= # app-dev.yourdomain.com -> localhost:3000 +# TUNNEL_KILOCLAW_HOSTNAME= # claw-dev.yourdomain.com -> localhost:8795 +# TUNNEL_KILOCHAT_HOSTNAME= # chat-dev.yourdomain.com -> localhost:8808 +# +# When true (default), named tunnel mode also writes .env.local values: +# APP_URL_OVERRIDE, NEXTAUTH_URL, and KILOCLAW_API_URL. +# TUNNEL_UPDATE_APP_ENV=true # RSA private key for agent env var encryption (get from 1Password, engineering vault) # AGENT_ENV_VARS_PRIVATE_KEY=