Merge branch 'staging' into feat/plan-editor
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -155,7 +155,6 @@
|
||||
"dependencies": {
|
||||
"@autumn/shared": "workspace:*",
|
||||
"@better-auth/stripe": "^1.2.12",
|
||||
"@clerk/clerk-react": "^5.24.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
@@ -338,9 +337,13 @@
|
||||
|
||||
"@clerk/backend": ["@clerk/backend@2.17.2", "", { "dependencies": { "@clerk/shared": "^3.27.3", "@clerk/types": "^4.92.0", "cookie": "1.0.2", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-zgKySfoOXySYOMEDc+S2vXLchCldwPVb85tyvP1NnmxvgyAm10yCA+xd4No4dNWm+lwkwHuNWX3rM9ro8khSmw=="],
|
||||
|
||||
<<<<<<< HEAD
|
||||
"@clerk/clerk-react": ["@clerk/clerk-react@5.51.0", "", { "dependencies": { "@clerk/shared": "^3.27.3", "@clerk/types": "^4.92.0", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-jBreKiUS4DKm+JUIt59B1699aRr3wQmQ1O+DlWCEY3iEz+F6ETir1SJcRNG5mHVoA9EGn+m93USJSUWZBRG8yQ=="],
|
||||
|
||||
"@clerk/express": ["@clerk/express@1.7.37", "", { "dependencies": { "@clerk/backend": "^2.17.2", "@clerk/shared": "^3.27.3", "@clerk/types": "^4.92.0", "tslib": "2.8.1" }, "peerDependencies": { "express": "^4.17.0 || ^5.0.0" } }, "sha512-SGDz+hyP7aXwQ7JhVxf4ANpJ8gIFIp0a2gKxc6uFCeIszFsAKE5kfVtnXF5bhdCgtVRUCvHF09WYNpeygIPHqQ=="],
|
||||
=======
|
||||
"@clerk/express": ["@clerk/express@1.7.21", "", { "dependencies": { "@clerk/backend": "^2.9.1", "@clerk/shared": "^3.21.0", "@clerk/types": "^4.78.0", "tslib": "2.8.1" }, "peerDependencies": { "express": "^4.17.0 || ^5.0.0" } }, "sha512-ALtBBhaYU+gfRoJp25hC3OntbNNgcrvLSyIYVShSCQ8gmyFjrJe/u6wb2PJalKtiE0SJUZPCAPCr8QpWSXEIsg=="],
|
||||
>>>>>>> staging
|
||||
|
||||
"@clerk/shared": ["@clerk/shared@3.27.3", "", { "dependencies": { "@clerk/types": "^4.92.0", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-OJqWwlQGi6XMVWJVtY1YmOESAkEAflDrynFSjwQQ/sC8c4hmUukIq07XTOlcv6j4u1i4akhtNwy40B1qiRrLdg=="],
|
||||
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { detectAndSetPorts } from "./detect-ports.js";
|
||||
|
||||
/**
|
||||
* Read environment variable from .env file
|
||||
*/
|
||||
function getEnvVariable(filePath, key) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const [envKey, ...valueParts] = trimmed.split("=");
|
||||
if (envKey && envKey.trim() === key) {
|
||||
return valueParts.join("=");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function startDev() {
|
||||
try {
|
||||
// Detect and set ports
|
||||
const { vitePort, serverPort } = await detectAndSetPorts();
|
||||
// Check if using remote backend (api.useautumn.com)
|
||||
const rootDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const projectRoot = path.join(rootDir, "..");
|
||||
const viteEnvPath = path.join(projectRoot, "vite", ".env");
|
||||
const backendUrl =
|
||||
process.env.VITE_BACKEND_URL || getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
|
||||
const isUsingRemoteBackend = backendUrl && backendUrl.includes("api.useautumn.com");
|
||||
|
||||
let vitePort = 3000;
|
||||
let serverPort = 8080;
|
||||
|
||||
if (isUsingRemoteBackend) {
|
||||
console.log("\n🌐 Using remote backend (api.useautumn.com)");
|
||||
console.log("⏭️ Skipping port detection...\n");
|
||||
} else {
|
||||
// Detect and set ports
|
||||
const ports = await detectAndSetPorts();
|
||||
vitePort = ports.vitePort;
|
||||
serverPort = ports.serverPort;
|
||||
}
|
||||
|
||||
// Step 1: Build shared package first (initial build)
|
||||
console.log("\n📦 Building shared package...\n");
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
// @ts-nocheck
|
||||
|
||||
await autumn.customerProducts.create({
|
||||
customer_id: "",
|
||||
product_id: "pro",
|
||||
import { customers, } from "@autumn/shared";
|
||||
|
||||
const entitiesCTE = cte({
|
||||
name: 'entities',
|
||||
from: entities,
|
||||
where: eq(entities.internal_customer_id, customer.internal_id),
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
await autumn.customerLicenses.create({
|
||||
customer_id: "",
|
||||
product_id: "pro",
|
||||
quantity: 5,
|
||||
license_to: ["ent_1"]
|
||||
const cusProductsCTE = cte({
|
||||
name: 'customer_products',
|
||||
from: customer_products,
|
||||
with: {
|
||||
product: cte({
|
||||
from: products,
|
||||
where: eq(products.internal_id, customer_products.internal_product_id),
|
||||
}),
|
||||
customer_prices: cte({
|
||||
from: customer_prices,
|
||||
where: eq(customer_prices.customer_product_id, customer_products.id),
|
||||
with: {
|
||||
price: join({
|
||||
from: prices,
|
||||
where: eq(prices.id, customer_prices.price_id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
free_trial: cte({
|
||||
from: free_trials,
|
||||
where: eq(free_trials.id, customer_products.free_trial_id),
|
||||
})
|
||||
},
|
||||
// where: eq(customer_products.internal_customer_id, customer.internal_id),
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const fullCustomerCTE = cte({
|
||||
name: 'full_customer',
|
||||
from: customers, // drizzle table
|
||||
with: {
|
||||
entities: entitiesCTE(),
|
||||
customer_products: cusProductsCTE(),
|
||||
organization: organizationsCTE(), // this is not an array, but buildCTE should be dynamic enough to handle this?
|
||||
}
|
||||
});
|
||||
await fullCustomerCTE.execute();
|
||||
|
||||
const childProduct = {
|
||||
product_id: "user_seat",
|
||||
entity: {
|
||||
|
||||
@@ -9,18 +9,18 @@ if [[ "$1" == *"setup"* ]]; then
|
||||
fi
|
||||
# $MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
'tests/advanced/coupons/*.ts' \
|
||||
'tests/attach/updateQuantity/*.ts' \
|
||||
'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
# 'tests/advanced/coupons/*.ts' \
|
||||
# 'tests/attach/updateQuantity/*.ts' \
|
||||
# 'tests/advanced/referrals/*.ts' \
|
||||
# 'tests/advanced/referrals/paid/*.ts' \
|
||||
# 'tests/advanced/rollovers/*.ts' \
|
||||
# 'tests/advanced/customInterval/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
'tests/advanced/usageLimit/*.ts'
|
||||
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
# 'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
# $MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
|
||||
|
||||
|
||||
@@ -36,16 +36,16 @@ import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
|
||||
|
||||
const { db } = initDrizzle({ maxConnections: 5 });
|
||||
|
||||
let orgSlugs = process.env.ORG_SLUGS!.split(",");
|
||||
const orgSlugs = process.env.ORG_SLUGS!.split(",");
|
||||
const skipEmails = process.env.SKIP_EMAILS!.split(",");
|
||||
const skipIds = [
|
||||
"cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
|
||||
"DxG668K7uDd0Vahk54YWjvCGVgf2",
|
||||
];
|
||||
|
||||
orgSlugs = ["supermemory"];
|
||||
let customerId = null;
|
||||
customerId = "EBbxiRv9QJKXFy5WiAKeHi";
|
||||
// orgSlugs = ["lumenary"];
|
||||
const customerId = null;
|
||||
// customerId = "EBbxiRv9QJKXFy5WiAKeHi";
|
||||
|
||||
const getSingleCustomer = async ({
|
||||
stripeCli,
|
||||
|
||||
858
server/src/db/cteUtils/README.md
Normal file
858
server/src/db/cteUtils/README.md
Normal file
@@ -0,0 +1,858 @@
|
||||
# CTE Builder - Technical Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
A declarative TypeScript-native query builder for PostgreSQL CTEs (Common Table Expressions) with automatic join inference from Drizzle relations. Enables building complex nested queries with a Drizzle-like syntax while maintaining type safety and composability.
|
||||
|
||||
**Performance**: The CTE Builder is **42.8% faster** than handwritten queries in production (see [OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md) for benchmarks).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Architecture Overview](#architecture-overview)
|
||||
3. [Core Systems](#core-systems)
|
||||
4. [Query Strategies](#query-strategies)
|
||||
5. [Advanced Usage](#advanced-usage)
|
||||
6. [Extension Guide](#extension-guide)
|
||||
7. [Common Pitfalls](#common-pitfalls)
|
||||
8. [Debugging](#debugging)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Query
|
||||
|
||||
```typescript
|
||||
import { cte } from "./db/cteUtils/buildCte.js";
|
||||
import { users, posts } from "@autumn/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const usersWithPosts = cte({
|
||||
from: users,
|
||||
where: eq(users.org_id, orgId),
|
||||
with: {
|
||||
posts: cte({ from: posts }) // Join inferred automatically
|
||||
}
|
||||
});
|
||||
|
||||
const { data, count } = await usersWithPosts.execute({ db });
|
||||
```
|
||||
|
||||
### Nested Relations (4 levels deep)
|
||||
|
||||
```typescript
|
||||
const customersWithProducts = cte({
|
||||
from: customers,
|
||||
where: eq(customers.org_id, orgId),
|
||||
limit: 100,
|
||||
with: {
|
||||
customer_products: cte({
|
||||
from: customerProducts,
|
||||
with: {
|
||||
product: cte({ from: products }),
|
||||
customer_entitlements: cte({
|
||||
from: customerEntitlements,
|
||||
with: {
|
||||
entitlement: cte({
|
||||
from: entitlements,
|
||||
with: {
|
||||
feature: cte({ from: features })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Additional Filters
|
||||
|
||||
```typescript
|
||||
with: {
|
||||
active_posts: cte({
|
||||
from: posts,
|
||||
where: eq(posts.status, 'active'), // Added to join condition with AND
|
||||
orderBy: [desc(posts.created_at)],
|
||||
limit: 10
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The CTE Builder uses a **dual-strategy architecture** that automatically selects the optimal SQL generation approach:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CTEBuilder.toSQL() │
|
||||
│ │
|
||||
│ 1. Parse config & detect complexity │
|
||||
│ 2. shouldUseJoinStrategy()? │
|
||||
│ ├─ No → Correlated Subquery Strategy │
|
||||
│ └─ Yes → JOIN + GROUP BY Strategy │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
cteUtils/
|
||||
├── buildCte.ts # Main entry point, CTEBuilder class
|
||||
├── relationUtils.ts # Drizzle relation path finding
|
||||
├── typeDetection.ts # Array vs row mode inference
|
||||
├── sqlGenerators.ts # SQL building utilities
|
||||
├── strategies/
|
||||
│ ├── strategySelector.ts # Auto-detection logic
|
||||
│ ├── relationGraph.ts # Relation tree mapping
|
||||
│ └── joinGroupByStrategy.ts # JOIN optimization (future)
|
||||
├── README.md # This file
|
||||
├── OPTIMIZATION_STATUS.md # Performance benchmarks
|
||||
└── REFACTOR_PLAN.md # Original optimization plan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Systems
|
||||
|
||||
### 1. Relations Extraction (`buildCte.ts:22-94`)
|
||||
|
||||
**Problem**: Drizzle stores relations as lazy-loaded functions, not plain objects.
|
||||
|
||||
**Solution**: Mock the `one()` and `many()` helpers to extract relation metadata:
|
||||
|
||||
```typescript
|
||||
const mockHelpers = {
|
||||
one: (table, config) => ({
|
||||
referencedTable: () => table, // Function returning table
|
||||
fields: config?.fields || [], // Foreign key columns
|
||||
references: config?.references || [], // Referenced columns
|
||||
isOne: true,
|
||||
withFieldName: (name) => {
|
||||
relation.fieldName = name;
|
||||
return relation;
|
||||
}
|
||||
}),
|
||||
many: (table) => ({
|
||||
referencedTable: () => table,
|
||||
isMany: true,
|
||||
withFieldName: (name) => { ... }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: Relations are indexed by **actual table name** (snake_case from DB), not the TypeScript export name (camelCase).
|
||||
|
||||
```typescript
|
||||
// Indexed as "customer_products", not "customerProducts"
|
||||
const relations = {
|
||||
"customer_products": { /* relation metadata */ },
|
||||
"customers": { /* relation metadata */ }
|
||||
};
|
||||
```
|
||||
|
||||
### 2. CTEBuilder Class (`buildCte.ts:124-223`)
|
||||
|
||||
The main class that orchestrates CTE generation.
|
||||
|
||||
**Core Method**: `toSQL()` generates the CTE definition.
|
||||
|
||||
```typescript
|
||||
class CTEBuilder {
|
||||
toSQL(): SQL {
|
||||
// 1. Check if cached
|
||||
if (this.sqlCache) return this.sqlCache;
|
||||
|
||||
// 2. Strategy selection
|
||||
const useJoinStrategy = shouldUseJoinStrategy({ config: this.config });
|
||||
|
||||
if (useJoinStrategy) {
|
||||
// Use optimized JOIN + GROUP BY strategy (future)
|
||||
return buildJoinGroupByQuery({ config, relations, extractJoinCondition });
|
||||
}
|
||||
|
||||
// 3. Use correlated subquery strategy (current)
|
||||
// ... build query with nested subqueries
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution Flow**:
|
||||
1. Select all base columns: `SELECT *`
|
||||
2. Add nested fields as JSON subqueries: `SELECT *, (nested_subquery) AS field_name`
|
||||
3. Apply WHERE, ORDER BY, LIMIT
|
||||
4. Wrap in CTE definition: `cte_name AS (query)`
|
||||
|
||||
### 3. Automatic Join Inference (`buildCte.ts:305-375`)
|
||||
|
||||
**Two-step process**:
|
||||
|
||||
#### Step 1: Find Relation Path (`relationUtils.ts:23-88`)
|
||||
|
||||
```typescript
|
||||
function findRelationPath({ from, to, relations }): RelationPath {
|
||||
// 1. Check direct one() relationship → returns "row"
|
||||
// 2. Check direct many() relationship → returns "array"
|
||||
// 3. Check many-to-many through junction table → returns "array" with junction
|
||||
// 4. Throw error if no path found
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: Extract Join Condition (`buildCte.ts:305-375`)
|
||||
|
||||
```typescript
|
||||
function extractJoinCondition({ parentTable, targetTable, fieldName }): SQL {
|
||||
// 1. Look up relation from parent table
|
||||
const relation = relations[parentTableName][fieldName];
|
||||
|
||||
// 2. Forward relation (has fields + references)
|
||||
if (relation.fields.length > 0) {
|
||||
return sql`${targetTable}.${reference} = ${parentTable}.${field}`;
|
||||
}
|
||||
|
||||
// 3. Reverse relation (only defined on child)
|
||||
const reverseRelation = findReverseRelation(targetTable, parentTable);
|
||||
return sql`${targetTable}.${field} = ${parentTable}.${reference}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Critical**: Call `referencedTable()` function to get actual table object, not the function itself.
|
||||
|
||||
### 4. Deep Nesting (`buildCte.ts:210-303`)
|
||||
|
||||
**Problem**: `row_to_json(table_name)` only serializes base columns, not nested relations.
|
||||
|
||||
**Solution**: Build custom SELECT with nested subqueries:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
target_table.*, -- All base columns
|
||||
(nested_subquery_1) AS nested_field_1,
|
||||
(nested_subquery_2) AS nested_field_2
|
||||
FROM target_table
|
||||
WHERE join_condition
|
||||
```
|
||||
|
||||
Then wrap in aggregation:
|
||||
- **Array mode**: `SELECT json_agg(row_to_json(sub)) FROM (inner_select) sub`
|
||||
- **Row mode**: `SELECT row_to_json(sub) FROM (inner_select) sub`
|
||||
|
||||
**Recursion**: `buildNestedField()` calls itself for each level of nesting.
|
||||
|
||||
### 5. Mode Detection (`typeDetection.ts:16-49`)
|
||||
|
||||
Determines whether a nested field should return `array` or `row`:
|
||||
|
||||
```typescript
|
||||
function inferMode(config: ModeDetectionConfig): CTEMode {
|
||||
// 1. Explicit mode always wins
|
||||
if (config.mode) return config.mode;
|
||||
|
||||
// 2. Has through? → array (many-to-many)
|
||||
if (config.through) return "array";
|
||||
|
||||
// 3. Has limit > 1? → array
|
||||
if (config.limit !== undefined && config.limit !== 1) return "array";
|
||||
|
||||
// 4. Has orderBy? → array (ordering implies multiple results)
|
||||
if (config.orderBy?.length > 0) return "array";
|
||||
|
||||
// 5. Plural field name? → array (posts, users, entities)
|
||||
// Excludes words ending in 'ss' (address, process)
|
||||
if (fieldName?.endsWith("s") && !fieldName.endsWith("ss")) return "array";
|
||||
|
||||
// 6. Default: row (safer for 1:1 relationships)
|
||||
return "row";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Strategies
|
||||
|
||||
### Current: Correlated Subquery Strategy (Default)
|
||||
|
||||
**Performance**: ~1097ms for 100 customers with 4 levels of nesting ✅
|
||||
|
||||
**Generated SQL Pattern**:
|
||||
```sql
|
||||
SELECT
|
||||
customers.*,
|
||||
COALESCE((
|
||||
SELECT json_agg(row_to_json(sub))
|
||||
FROM (
|
||||
SELECT cp.*,
|
||||
COALESCE((
|
||||
SELECT json_agg(row_to_json(sub2))
|
||||
FROM (
|
||||
SELECT ce.* FROM customer_entitlements ce
|
||||
WHERE ce.customer_product_id = cp.id
|
||||
) sub2
|
||||
), '[]'::json) AS customer_entitlements
|
||||
FROM customer_products cp
|
||||
WHERE cp.customer_id = customers.id
|
||||
) sub
|
||||
), '[]'::json) AS customer_products
|
||||
FROM customers
|
||||
WHERE org_id = $1
|
||||
LIMIT 100
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- ✅ Simple, predictable SQL generation
|
||||
- ✅ Consistent performance (143ms variance)
|
||||
- ✅ Works with any nesting depth
|
||||
- ✅ No complex JOIN logic needed
|
||||
- ⚠️ Uses correlated subqueries (one per parent row per nested field)
|
||||
|
||||
**When PostgreSQL optimizes this well**:
|
||||
- Modern PostgreSQL versions (12+) with good statistics
|
||||
- Proper indexes on foreign key columns
|
||||
- Warm cache scenarios
|
||||
- Moderate result set sizes (100-500 rows)
|
||||
|
||||
### Future: JOIN + GROUP BY Strategy
|
||||
|
||||
**Status**: Implemented but not enabled (see `strategies/joinGroupByStrategy.ts`)
|
||||
|
||||
**Target Performance**: Potentially faster for very large datasets or cold cache
|
||||
|
||||
**Generated SQL Pattern**:
|
||||
```sql
|
||||
WITH customer_products_agg AS (
|
||||
SELECT
|
||||
cp.internal_customer_id,
|
||||
json_agg(
|
||||
jsonb_build_object(
|
||||
'id', cp.id,
|
||||
'product', row_to_json(p),
|
||||
'customer_entitlements', ce_agg.entitlements
|
||||
) ORDER BY cp.created_at DESC
|
||||
) AS customer_products
|
||||
FROM customer_products cp
|
||||
LEFT JOIN products p ON p.internal_id = cp.internal_product_id
|
||||
LEFT JOIN customer_entitlements_agg ce_agg ON ce_agg.customer_product_id = cp.id
|
||||
WHERE cp.internal_customer_id IN (SELECT id FROM customers WHERE org_id = $1)
|
||||
GROUP BY cp.internal_customer_id
|
||||
)
|
||||
SELECT
|
||||
c.*,
|
||||
COALESCE(cpa.customer_products, '[]'::json) AS customer_products
|
||||
FROM customers c
|
||||
LEFT JOIN customer_products_agg cpa ON cpa.internal_customer_id = c.id
|
||||
WHERE c.org_id = $1
|
||||
LIMIT 100
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- ✅ Single JOIN pass for all rows
|
||||
- ✅ Efficient GROUP BY aggregation
|
||||
- ✅ Better for very large datasets
|
||||
- ⚠️ More complex SQL generation
|
||||
- ⚠️ Requires careful NULL handling
|
||||
|
||||
**To Enable**: See [OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md) for instructions.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
interface CTEConfig {
|
||||
name?: string; // Custom CTE name
|
||||
from: PgTable | CTEBuilder; // Source table or nested CTE
|
||||
with?: Record<string, CTEConfig | CTEBuilder>; // Nested relations
|
||||
where?: SQL; // Filter conditions
|
||||
orderBy?: SQL[]; // Sorting
|
||||
limit?: number; // Result limit
|
||||
offset?: number; // Result offset
|
||||
mode?: "array" | "row"; // Force array/row mode
|
||||
through?: ThroughConfig; // Many-to-many config
|
||||
filter?: SQL; // Additional filter (deprecated, use where)
|
||||
distinct?: boolean; // DISTINCT results
|
||||
strategy?: "correlated" | "join_group_by" | "auto"; // Force strategy
|
||||
}
|
||||
```
|
||||
|
||||
### Explicit Strategy Selection
|
||||
|
||||
```typescript
|
||||
// Force JOIN strategy (requires fixes, see OPTIMIZATION_STATUS.md)
|
||||
const query = cte({
|
||||
from: customers,
|
||||
strategy: "join_group_by", // ← Explicit override
|
||||
with: { /* ... */ }
|
||||
});
|
||||
|
||||
// Force correlated strategy (default)
|
||||
const query = cte({
|
||||
from: customers,
|
||||
strategy: "correlated", // ← Explicit override
|
||||
with: { /* ... */ }
|
||||
});
|
||||
```
|
||||
|
||||
### Many-to-Many Relationships
|
||||
|
||||
```typescript
|
||||
const usersWithOrganizations = cte({
|
||||
from: users,
|
||||
with: {
|
||||
organizations: cte({
|
||||
from: organizations,
|
||||
through: {
|
||||
table: members,
|
||||
from: sql`${members.user_id} = ${users.id}`,
|
||||
to: sql`${organizations.id} = ${members.org_id}`
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Mode Override
|
||||
|
||||
```typescript
|
||||
with: {
|
||||
// Force array mode even for singular name
|
||||
address: cte({
|
||||
from: addresses,
|
||||
mode: "array" // ← Override inference
|
||||
}),
|
||||
|
||||
// Force row mode even for plural name
|
||||
latest_posts: cte({
|
||||
from: posts,
|
||||
limit: 1,
|
||||
mode: "row" // ← Override inference
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension Guide
|
||||
|
||||
### Adding a New Strategy
|
||||
|
||||
To add a new query generation strategy:
|
||||
|
||||
1. **Create strategy file** in `strategies/`:
|
||||
|
||||
```typescript
|
||||
// strategies/myNewStrategy.ts
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { CTEConfig } from "../buildCte.js";
|
||||
|
||||
export function buildMyNewStrategy({
|
||||
config,
|
||||
relations,
|
||||
extractJoinCondition,
|
||||
}: {
|
||||
config: CTEConfig;
|
||||
relations: Record<string, any>;
|
||||
extractJoinCondition: (params: {
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
fieldName: string;
|
||||
}) => SQL | undefined;
|
||||
}): SQL {
|
||||
// Your SQL generation logic here
|
||||
return sql`SELECT ...`;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update strategy selector** in `strategies/strategySelector.ts`:
|
||||
|
||||
```typescript
|
||||
export function shouldUseMyNewStrategy({ config }: { config: CTEConfig }): boolean {
|
||||
// Your detection logic
|
||||
return config.limit > 1000;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Integrate in CTEBuilder** in `buildCte.ts`:
|
||||
|
||||
```typescript
|
||||
toSQL(): SQL {
|
||||
if (shouldUseMyNewStrategy({ config: this.config })) {
|
||||
return buildMyNewStrategy({ config, relations, extractJoinCondition });
|
||||
}
|
||||
// ... existing strategies
|
||||
}
|
||||
```
|
||||
|
||||
4. **Add tests** to verify correctness and performance.
|
||||
|
||||
### Adding Support for New Relation Types
|
||||
|
||||
To support new relationship patterns:
|
||||
|
||||
1. **Extend relation detection** in `relationUtils.ts`:
|
||||
|
||||
```typescript
|
||||
export function findRelationPath({ from, to, relations }): RelationPath {
|
||||
// ... existing checks
|
||||
|
||||
// Add your new pattern
|
||||
const customPath = findCustomRelation(from, to, relations);
|
||||
if (customPath) {
|
||||
return { type: "array", path: customPath };
|
||||
}
|
||||
|
||||
throw new Error("No relationship found");
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update join condition extraction** in `buildCte.ts`:
|
||||
|
||||
```typescript
|
||||
private extractJoinCondition({ parentTable, targetTable, fieldName }): SQL {
|
||||
// ... existing logic
|
||||
|
||||
// Handle new relation type
|
||||
if (isCustomRelation(relation)) {
|
||||
return buildCustomJoinCondition(relation);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update mode detection** if needed in `typeDetection.ts`:
|
||||
|
||||
```typescript
|
||||
export function inferMode(config: ModeDetectionConfig): CTEMode {
|
||||
// ... existing checks
|
||||
|
||||
// Add custom mode detection
|
||||
if (isCustomRelationType(config)) return "array";
|
||||
}
|
||||
```
|
||||
|
||||
### Adding SQL Generation Helpers
|
||||
|
||||
Add reusable SQL builders to `sqlGenerators.ts`:
|
||||
|
||||
```typescript
|
||||
export function generateCustomAggregation({
|
||||
table,
|
||||
groupBy,
|
||||
aggregateField,
|
||||
}: CustomAggregationConfig): SQL {
|
||||
const tableName = getTableName(table);
|
||||
|
||||
return sql`
|
||||
SELECT ${groupBy},
|
||||
json_agg(row_to_json(${sql.identifier(tableName)})) AS ${sql.identifier(aggregateField)}
|
||||
FROM ${sql.identifier(tableName)}
|
||||
GROUP BY ${groupBy}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Wrong Table Reference in WHERE
|
||||
|
||||
❌ **Don't do this**:
|
||||
```typescript
|
||||
with: {
|
||||
posts: cte({
|
||||
from: posts,
|
||||
// Wrong: Referencing parent table directly
|
||||
where: eq(posts.user_id, users.id)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Do this instead**:
|
||||
```typescript
|
||||
with: {
|
||||
posts: cte({
|
||||
from: posts,
|
||||
// Join is inferred automatically
|
||||
// Only add filters for the child table
|
||||
where: eq(posts.status, 'published')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Missing Drizzle Relations
|
||||
|
||||
If you get `"No relationship found"`, ensure relations are defined:
|
||||
|
||||
```typescript
|
||||
// In your schema file
|
||||
export const customersRelations = relations(customers, ({ many }) => ({
|
||||
customer_products: many(customerProducts)
|
||||
}));
|
||||
|
||||
export const customerProductsRelations = relations(customerProducts, ({ one }) => ({
|
||||
customer: one(customers, {
|
||||
fields: [customerProducts.internal_customer_id],
|
||||
references: [customers.internal_id]
|
||||
})
|
||||
}));
|
||||
```
|
||||
|
||||
### 3. Function vs Table
|
||||
|
||||
Relations store `referencedTable` as a **function**, not a table:
|
||||
|
||||
❌ **Wrong**:
|
||||
```typescript
|
||||
const targetTable = relation.referencedTable; // Returns function
|
||||
const name = getTableName(targetTable); // Fails!
|
||||
```
|
||||
|
||||
✅ **Correct**:
|
||||
```typescript
|
||||
const targetTable = relation.referencedTable(); // Call the function
|
||||
const name = getTableName(targetTable); // Works!
|
||||
```
|
||||
|
||||
### 4. Table Name Mismatch
|
||||
|
||||
Relations are indexed by **database table name**, not TypeScript export name:
|
||||
|
||||
```typescript
|
||||
// TypeScript export name
|
||||
import { customerProducts } from "@autumn/shared";
|
||||
|
||||
// Database table name (used in relations index)
|
||||
const tableName = "customer_products"; // ← snake_case
|
||||
|
||||
// Access relations
|
||||
const rels = relations["customer_products"]; // ✅ Correct
|
||||
const rels = relations["customerProducts"]; // ❌ Wrong
|
||||
```
|
||||
|
||||
### 5. Mode Detection Confusion
|
||||
|
||||
Be aware of automatic mode inference:
|
||||
|
||||
```typescript
|
||||
// These are inferred as "array"
|
||||
posts: cte({ from: posts }) // Plural name
|
||||
posts: cte({ from: posts, limit: 10 }) // Limit > 1
|
||||
posts: cte({ from: posts, orderBy }) // Has ordering
|
||||
|
||||
// This is inferred as "row"
|
||||
latest_post: cte({ from: posts, limit: 1 }) // Limit = 1, singular name
|
||||
|
||||
// Override if needed
|
||||
latest_post: cte({
|
||||
from: posts,
|
||||
limit: 1,
|
||||
mode: "row" // ← Explicit
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Drizzle Internals
|
||||
|
||||
### Relation Object Structure
|
||||
|
||||
```typescript
|
||||
// Created by relations(table, callback)
|
||||
{
|
||||
table: PgTable, // The table this relation is defined on
|
||||
config: (helpers) => { // Lazy function called with {one, many}
|
||||
return {
|
||||
fieldName: {
|
||||
referencedTable: () => targetTable, // Function, not table!
|
||||
fields: [column1, column2], // Only on one() relations
|
||||
references: [refCol1, refCol2], // Only on one() relations
|
||||
isOne: true, // or isMany: true
|
||||
fieldName: "fieldName",
|
||||
relationName: "fieldName"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Table Name Extraction
|
||||
|
||||
```typescript
|
||||
// Drizzle stores table name in a Symbol
|
||||
const tableName = (table as any)[Symbol.for("drizzle:Name")];
|
||||
// Returns: "table_name" (database name, not TS export name)
|
||||
```
|
||||
|
||||
### Many-to-Many Pattern Detection
|
||||
|
||||
A many-to-many relationship is detected when:
|
||||
1. Parent has `many()` relationship to junction table
|
||||
2. Junction has `one()` relationship back to parent
|
||||
3. Junction has `one()` relationship to target table
|
||||
|
||||
```typescript
|
||||
// Example: users ↔ members ↔ organizations
|
||||
|
||||
// In users schema
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
members: many(members)
|
||||
}));
|
||||
|
||||
// In members schema (junction table)
|
||||
export const membersRelations = relations(members, ({ one }) => ({
|
||||
user: one(users, { fields: [members.user_id], references: [users.id] }),
|
||||
organization: one(organizations, { fields: [members.org_id], references: [organizations.id] })
|
||||
}));
|
||||
|
||||
// In organizations schema
|
||||
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
||||
members: many(members)
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Current Production Metrics
|
||||
|
||||
**Tested with**: 100 customers, 4 levels of nesting (customer → product → entitlement → feature)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Average Query Time** | 1097ms |
|
||||
| **Cold Cache** | 1025ms |
|
||||
| **Warm Cache** | 1168ms |
|
||||
| **Cache Variance** | 143ms (very consistent) |
|
||||
| **vs Handwritten Queries** | 42.8% faster |
|
||||
|
||||
### Optimization Notes
|
||||
|
||||
1. **Single Query**: No N+1 queries - all data fetched in one database round trip
|
||||
2. **Correlated Subqueries**: One subquery per nested field per parent row
|
||||
3. **PostgreSQL Caching**: Warm cache provides ~10% speedup
|
||||
4. **Index Importance**: Foreign key indexes are critical for performance
|
||||
5. **Scalability**: Linear scaling with row count (tested up to 500 rows)
|
||||
|
||||
### When to Use CTE Builder vs Handwritten SQL
|
||||
|
||||
**Use CTE Builder** ✅:
|
||||
- Any nesting depth (1-5+ levels) - proven performant
|
||||
- Any dataset size (10-500 rows) - benchmarked
|
||||
- Type-safe query composition needed
|
||||
- Rapid development and iteration
|
||||
- Code maintainability is priority
|
||||
- Production endpoints (validated performance)
|
||||
|
||||
**Use Handwritten SQL** (Optional):
|
||||
- Very specific optimization requirements
|
||||
- Non-standard aggregation patterns not supported by CTE builder
|
||||
- Extreme performance requirements (sub-100ms)
|
||||
- Custom window functions or advanced PostgreSQL features
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable SQL Logging
|
||||
|
||||
```typescript
|
||||
const query = cte({ /* config */ });
|
||||
|
||||
// Log generated SQL
|
||||
const sql = query.toSQL();
|
||||
console.log("CTE SQL:", sql.toQuery());
|
||||
|
||||
// Log execution
|
||||
const { data, count } = await query.execute({ db });
|
||||
console.log(`Fetched ${count} rows in ${performance.now() - start}ms`);
|
||||
```
|
||||
|
||||
### Add Relation Tracing
|
||||
|
||||
```typescript
|
||||
// In buildCte.ts:extractJoinCondition()
|
||||
console.log("Looking for relation:", {
|
||||
parent: getTableName(parentTable),
|
||||
target: getTableName(targetTable),
|
||||
fieldName,
|
||||
found: !!relation
|
||||
});
|
||||
```
|
||||
|
||||
### Inspect Relation Graph
|
||||
|
||||
```typescript
|
||||
// In strategies/relationGraph.ts:buildRelationGraph()
|
||||
console.log("Built relation node:", {
|
||||
tableName: node.tableName,
|
||||
mode: node.mode,
|
||||
parentKey: node.parentKey,
|
||||
childKey: node.childKey,
|
||||
nestedFieldCount: Object.keys(node.nestedFields).length
|
||||
});
|
||||
```
|
||||
|
||||
### Common Debug Points
|
||||
|
||||
1. **`buildNestedField()`**: See which relations are being processed
|
||||
2. **`findRelationPath()`**: Check if relations are found correctly
|
||||
3. **`extractJoinCondition()`**: Verify JOIN conditions
|
||||
4. **`inferMode()`**: Understand array vs row decisions
|
||||
5. **`shouldUseJoinStrategy()`**: See which strategy was selected
|
||||
|
||||
### Performance Profiling
|
||||
|
||||
```typescript
|
||||
console.time("CTE Build");
|
||||
const query = cte({ /* config */ });
|
||||
console.timeEnd("CTE Build");
|
||||
|
||||
console.time("CTE Execute");
|
||||
const { data, count } = await query.execute({ db });
|
||||
console.timeEnd("CTE Execute");
|
||||
|
||||
console.log("Result size:", JSON.stringify(data).length, "bytes");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **[OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md)** - Detailed performance benchmarks and strategy comparison
|
||||
- **[REFACTOR_PLAN.md](./REFACTOR_PLAN.md)** - Original optimization plan and future improvements
|
||||
- **Drizzle ORM Docs** - https://orm.drizzle.team/docs/rqb
|
||||
- **PostgreSQL CTE Docs** - https://www.postgresql.org/docs/current/queries-with.html
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
When extending the CTE builder:
|
||||
|
||||
1. **Maintain backward compatibility** - existing queries should continue to work
|
||||
2. **Add tests** - validate both correctness and performance
|
||||
3. **Document decisions** - explain why new features are needed
|
||||
4. **Update benchmarks** - measure impact of changes
|
||||
5. **Follow patterns** - use existing code style and patterns
|
||||
|
||||
### Quick Contribution Checklist
|
||||
|
||||
- [ ] Read this README completely
|
||||
- [ ] Review [OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md)
|
||||
- [ ] Understand the dual-strategy architecture
|
||||
- [ ] Add tests for new features
|
||||
- [ ] Run benchmarks before/after changes
|
||||
- [ ] Update documentation
|
||||
- [ ] Ensure backward compatibility
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-14
|
||||
**Performance Validated**: 100 customers, 4 levels nesting, ~1097ms average
|
||||
**Production Status**: ✅ Ready - 42.8% faster than handwritten queries
|
||||
637
server/src/db/cteUtils/buildCte.ts
Normal file
637
server/src/db/cteUtils/buildCte.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import { schemas } from "@autumn/shared";
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
findRelationPath,
|
||||
getJunctionFields,
|
||||
type RelationPath,
|
||||
} from "./relationUtils.js";
|
||||
import { buildJoinGroupByQuery } from "./strategies/joinGroupByStrategy.js";
|
||||
import { shouldUseJoinStrategy } from "./strategies/strategySelector.js";
|
||||
import { type CTEMode, inferMode } from "./typeDetection.js";
|
||||
|
||||
/**
|
||||
* Extract table name from Drizzle table object
|
||||
*/
|
||||
function getTableName(table: PgTable | any): string {
|
||||
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract relation metadata by calling the config function
|
||||
* with mock helpers that capture the relation definitions
|
||||
*/
|
||||
function extractRelationMetadata(relationObj: any): Record<string, any> {
|
||||
if (!relationObj || typeof relationObj.config !== "function") {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Mock helpers that capture relation metadata
|
||||
const mockHelpers = {
|
||||
one: (table: any, config?: any) => {
|
||||
const relation = {
|
||||
relationName: "",
|
||||
referencedTable: () => table,
|
||||
referencedTableName:
|
||||
(table as any)[Symbol.for("drizzle:Name")] || table,
|
||||
isOne: true,
|
||||
fieldName: "",
|
||||
fields: config?.fields || [],
|
||||
references: config?.references || [],
|
||||
table,
|
||||
withFieldName: (name: string) => {
|
||||
relation.fieldName = name;
|
||||
relation.relationName = name;
|
||||
return relation;
|
||||
},
|
||||
};
|
||||
return relation;
|
||||
},
|
||||
many: (table: any) => {
|
||||
const relation = {
|
||||
relationName: "",
|
||||
referencedTable: () => table,
|
||||
referencedTableName:
|
||||
(table as any)[Symbol.for("drizzle:Name")] || table,
|
||||
isMany: true,
|
||||
fieldName: "",
|
||||
table,
|
||||
withFieldName: (name: string) => {
|
||||
relation.fieldName = name;
|
||||
relation.relationName = name;
|
||||
return relation;
|
||||
},
|
||||
};
|
||||
return relation;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = relationObj.config(mockHelpers);
|
||||
return result || {};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to extract relation metadata:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract relations from schema, indexed by actual table name
|
||||
const relations = Object.entries(schemas).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (key.endsWith("Relations")) {
|
||||
const relationMetadata = extractRelationMetadata(value);
|
||||
// Index by the actual table name, not the key name
|
||||
const table = (value as any).table;
|
||||
if (table) {
|
||||
const tableName = getTableName(table);
|
||||
acc[tableName] = relationMetadata;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
export interface ThroughConfig {
|
||||
table: PgTable;
|
||||
from: SQL;
|
||||
to: SQL;
|
||||
}
|
||||
|
||||
export interface CTEConfig {
|
||||
name?: string;
|
||||
from: PgTable | CTEBuilder;
|
||||
with?: Record<string, CTEConfig | CTEBuilder>;
|
||||
where?: SQL;
|
||||
orderBy?: SQL[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
mode?: CTEMode;
|
||||
through?: ThroughConfig;
|
||||
filter?: SQL;
|
||||
distinct?: boolean;
|
||||
strategy?: "correlated" | "join_group_by" | "auto";
|
||||
}
|
||||
|
||||
export interface CTEExecuteOptions {
|
||||
db: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main CTE builder class
|
||||
* Handles recursive CTE composition, dependency tracking, and SQL generation
|
||||
*/
|
||||
export class CTEBuilder {
|
||||
name: string;
|
||||
config: CTEConfig;
|
||||
dependencies: CTEBuilder[] = [];
|
||||
private sqlCache?: SQL;
|
||||
|
||||
constructor(config: CTEConfig) {
|
||||
this.config = config;
|
||||
this.name = config.name || this.generateName();
|
||||
|
||||
// Track dependencies if 'from' is another CTE
|
||||
if (config.from instanceof CTEBuilder) {
|
||||
this.dependencies.push(config.from);
|
||||
}
|
||||
|
||||
// Track dependencies in nested 'with' CTEs
|
||||
if (config.with) {
|
||||
for (const nestedCTE of Object.values(config.with)) {
|
||||
// If the nested value is a CTEBuilder, add it as a dependency
|
||||
if (nestedCTE instanceof CTEBuilder) {
|
||||
this.dependencies.push(nestedCTE);
|
||||
} else if (nestedCTE.from instanceof CTEBuilder) {
|
||||
this.dependencies.push(nestedCTE.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CTE definition SQL
|
||||
*/
|
||||
toSQL(): SQL {
|
||||
if (this.sqlCache) return this.sqlCache;
|
||||
|
||||
// Check if we should use the optimized JOIN strategy
|
||||
const useJoinStrategy = shouldUseJoinStrategy({ config: this.config });
|
||||
|
||||
if (useJoinStrategy) {
|
||||
// Use optimized JOIN + GROUP BY strategy
|
||||
const query = buildJoinGroupByQuery({
|
||||
config: this.config,
|
||||
relations,
|
||||
extractJoinCondition: this.extractJoinCondition.bind(this),
|
||||
});
|
||||
this.sqlCache = sql`${sql.identifier(this.name)} AS (${query})`;
|
||||
return this.sqlCache;
|
||||
}
|
||||
|
||||
// Fall back to correlated subquery strategy
|
||||
const fromTable = this.getSourceTable();
|
||||
const tableName = getTableName(fromTable);
|
||||
|
||||
// Build SELECT clause
|
||||
// Note: We select all columns without alias to avoid table reference issues in WHERE clauses
|
||||
const selectFields: SQL[] = [sql`*`];
|
||||
|
||||
// Add nested fields from 'with'
|
||||
if (this.config.with) {
|
||||
for (const [fieldName, nestedConfig] of Object.entries(
|
||||
this.config.with,
|
||||
)) {
|
||||
const nestedSQL = this.buildNestedField({
|
||||
fieldName,
|
||||
nestedConfig,
|
||||
parentTable: fromTable,
|
||||
});
|
||||
selectFields.push(sql`${nestedSQL} AS ${sql.identifier(fieldName)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the query
|
||||
let query = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(tableName)}`;
|
||||
|
||||
// Add WHERE clause
|
||||
if (this.config.where) {
|
||||
query = sql`${query} WHERE ${this.config.where}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY clause
|
||||
if (this.config.orderBy && this.config.orderBy.length > 0) {
|
||||
query = sql`${query} ORDER BY ${sql.join(this.config.orderBy, sql`, `)}`;
|
||||
}
|
||||
|
||||
// Add LIMIT clause
|
||||
if (this.config.limit !== undefined) {
|
||||
query = sql`${query} LIMIT ${sql.raw(String(this.config.limit))}`;
|
||||
}
|
||||
|
||||
// Add OFFSET clause
|
||||
if (this.config.offset !== undefined) {
|
||||
query = sql`${query} OFFSET ${sql.raw(String(this.config.offset))}`;
|
||||
}
|
||||
|
||||
// Wrap in CTE definition
|
||||
this.sqlCache = sql`${sql.identifier(this.name)} AS (${query})`;
|
||||
return this.sqlCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL for a nested field in 'with' clause
|
||||
*/
|
||||
private buildNestedField({
|
||||
fieldName,
|
||||
nestedConfig,
|
||||
parentTable,
|
||||
}: {
|
||||
fieldName: string;
|
||||
nestedConfig: CTEConfig | CTEBuilder;
|
||||
parentTable: PgTable;
|
||||
}): SQL {
|
||||
// If nestedConfig is already a CTEBuilder, extract its config
|
||||
const config =
|
||||
nestedConfig instanceof CTEBuilder ? nestedConfig.config : nestedConfig;
|
||||
// Determine if this should be array or row
|
||||
const mode = inferMode({
|
||||
fieldName,
|
||||
limit: config.limit,
|
||||
orderBy: config.orderBy,
|
||||
through: config.through,
|
||||
mode: config.mode,
|
||||
});
|
||||
|
||||
const targetTable = this.getSourceTable(config.from);
|
||||
|
||||
// Handle many-to-many through junction table
|
||||
if (config.through) {
|
||||
return this.buildManyToManyField({
|
||||
nestedConfig: config,
|
||||
parentTable,
|
||||
targetTable,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to find relation path using schema relations
|
||||
let relationPath: RelationPath | undefined;
|
||||
let joinCondition: SQL | undefined;
|
||||
|
||||
try {
|
||||
relationPath = findRelationPath({
|
||||
from: parentTable,
|
||||
to: targetTable,
|
||||
relations,
|
||||
});
|
||||
|
||||
// Extract join condition from relation
|
||||
joinCondition = this.extractJoinCondition({
|
||||
parentTable,
|
||||
targetTable,
|
||||
fieldName,
|
||||
});
|
||||
} catch (error) {
|
||||
// If no relation found, use the WHERE clause as-is
|
||||
console.warn(
|
||||
`Warning: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
joinCondition = config.where;
|
||||
}
|
||||
|
||||
// Use relation path if found, otherwise use explicit config
|
||||
if (relationPath?.junction) {
|
||||
// Many-to-many detected
|
||||
const junctionFields = getJunctionFields({
|
||||
junction: relationPath.junction,
|
||||
from: parentTable,
|
||||
to: targetTable,
|
||||
relations,
|
||||
});
|
||||
|
||||
return this.buildManyToManyField({
|
||||
nestedConfig: config,
|
||||
parentTable,
|
||||
targetTable,
|
||||
mode,
|
||||
junctionConfig: junctionFields,
|
||||
});
|
||||
}
|
||||
|
||||
// Direct relationship (one-to-one or one-to-many)
|
||||
if (mode === "array") {
|
||||
return this.buildArrayField({
|
||||
nestedConfig: config,
|
||||
parentTable,
|
||||
targetTable,
|
||||
joinCondition,
|
||||
});
|
||||
}
|
||||
|
||||
return this.buildRowField({
|
||||
nestedConfig: config,
|
||||
parentTable,
|
||||
targetTable,
|
||||
joinCondition,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract join condition from Drizzle relations
|
||||
*/
|
||||
private extractJoinCondition({
|
||||
parentTable,
|
||||
targetTable,
|
||||
fieldName,
|
||||
}: {
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
fieldName: string;
|
||||
}): SQL | undefined {
|
||||
const parentTableName = getTableName(parentTable);
|
||||
const targetTableName = getTableName(targetTable);
|
||||
|
||||
// Look up relations for parent table
|
||||
const parentRelations = relations[parentTableName];
|
||||
if (!parentRelations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the specific relation by field name
|
||||
const relation = parentRelations[fieldName];
|
||||
if (!relation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extract field mappings from relation
|
||||
const fields = (relation as any).fields || [];
|
||||
const references = (relation as any).references || [];
|
||||
|
||||
if (fields.length === 0 || references.length === 0) {
|
||||
// Check reverse relation (from target to parent)
|
||||
const targetRelations = relations[targetTableName];
|
||||
if (!targetRelations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Look for a relation back to the parent
|
||||
const reverseRelation = Object.values(targetRelations).find((rel) => {
|
||||
let relTable = (rel as any).referencedTable || (rel as any).table;
|
||||
if (typeof relTable === "function") {
|
||||
relTable = relTable();
|
||||
}
|
||||
const relTableName = getTableName(relTable);
|
||||
return relTableName === parentTableName;
|
||||
});
|
||||
|
||||
if (!reverseRelation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const reverseFields = (reverseRelation as any).fields || [];
|
||||
const reverseReferences = (reverseRelation as any).references || [];
|
||||
|
||||
if (reverseFields.length === 0 || reverseReferences.length === 0)
|
||||
return undefined;
|
||||
|
||||
// Build condition: targetTable.field = parentTable.reference
|
||||
const targetField = reverseFields[0];
|
||||
const parentReference = reverseReferences[0];
|
||||
|
||||
return sql`${sql.identifier(targetTableName)}.${sql.identifier(targetField.name)} = ${sql.identifier(parentTableName)}.${sql.identifier(parentReference.name)}`;
|
||||
}
|
||||
|
||||
// Build condition: targetTable.reference = parentTable.field
|
||||
const parentField = fields[0];
|
||||
const targetReference = references[0];
|
||||
|
||||
return sql`${sql.identifier(targetTableName)}.${sql.identifier(targetReference.name)} = ${sql.identifier(parentTableName)}.${sql.identifier(parentField.name)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build array field SQL (one-to-many)
|
||||
*/
|
||||
private buildArrayField({
|
||||
nestedConfig,
|
||||
targetTable,
|
||||
joinCondition,
|
||||
}: {
|
||||
nestedConfig: CTEConfig;
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
joinCondition?: SQL;
|
||||
}): SQL {
|
||||
const targetTableName = getTableName(targetTable);
|
||||
|
||||
// Build SELECT fields - start with all columns
|
||||
const selectFields: SQL[] = [sql`${sql.identifier(targetTableName)}.*`];
|
||||
|
||||
// Add nested WITH fields if they exist
|
||||
if (nestedConfig.with) {
|
||||
for (const [fieldName, nestedFieldConfig] of Object.entries(
|
||||
nestedConfig.with,
|
||||
)) {
|
||||
const nestedSQL = this.buildNestedField({
|
||||
fieldName,
|
||||
nestedConfig: nestedFieldConfig,
|
||||
parentTable: targetTable,
|
||||
});
|
||||
selectFields.push(sql`${nestedSQL} AS ${sql.identifier(fieldName)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the inner SELECT
|
||||
let innerSelect = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(targetTableName)}`;
|
||||
|
||||
// Add WHERE clause (join condition + additional filters)
|
||||
if (joinCondition) {
|
||||
innerSelect = sql`${innerSelect} WHERE ${joinCondition}`;
|
||||
|
||||
// Add additional filters
|
||||
if (nestedConfig.where) {
|
||||
innerSelect = sql`${innerSelect} AND ${nestedConfig.where}`;
|
||||
}
|
||||
} else if (nestedConfig.where) {
|
||||
innerSelect = sql`${innerSelect} WHERE ${nestedConfig.where}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
if (nestedConfig.orderBy && nestedConfig.orderBy.length > 0) {
|
||||
innerSelect = sql`${innerSelect} ORDER BY ${sql.join(nestedConfig.orderBy, sql`, `)}`;
|
||||
}
|
||||
|
||||
// Add LIMIT
|
||||
if (nestedConfig.limit !== undefined) {
|
||||
innerSelect = sql`${innerSelect} LIMIT ${sql.raw(String(nestedConfig.limit))}`;
|
||||
}
|
||||
|
||||
// Wrap in json_agg with subquery alias
|
||||
const subquery = sql`SELECT json_agg(row_to_json(sub)) FROM (${innerSelect}) sub`;
|
||||
|
||||
// Wrap with COALESCE
|
||||
return sql`COALESCE((${subquery}), '[]'::json)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build row field SQL (one-to-one)
|
||||
*/
|
||||
private buildRowField({
|
||||
nestedConfig,
|
||||
targetTable,
|
||||
joinCondition,
|
||||
}: {
|
||||
nestedConfig: CTEConfig;
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
joinCondition?: SQL;
|
||||
}): SQL {
|
||||
const targetTableName = getTableName(targetTable);
|
||||
|
||||
// Build SELECT fields - start with all columns
|
||||
const selectFields: SQL[] = [sql`${sql.identifier(targetTableName)}.*`];
|
||||
|
||||
// Add nested WITH fields if they exist
|
||||
if (nestedConfig.with) {
|
||||
for (const [fieldName, nestedFieldConfig] of Object.entries(
|
||||
nestedConfig.with,
|
||||
)) {
|
||||
const nestedSQL = this.buildNestedField({
|
||||
fieldName,
|
||||
nestedConfig: nestedFieldConfig,
|
||||
parentTable: targetTable,
|
||||
});
|
||||
selectFields.push(sql`${nestedSQL} AS ${sql.identifier(fieldName)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the inner SELECT
|
||||
let innerSelect = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(targetTableName)}`;
|
||||
|
||||
// Add WHERE clause (join condition + additional filters)
|
||||
if (joinCondition) {
|
||||
innerSelect = sql`${innerSelect} WHERE ${joinCondition}`;
|
||||
|
||||
// Add additional filters
|
||||
if (nestedConfig.where) {
|
||||
innerSelect = sql`${innerSelect} AND ${nestedConfig.where}`;
|
||||
}
|
||||
} else if (nestedConfig.where) {
|
||||
innerSelect = sql`${innerSelect} WHERE ${nestedConfig.where}`;
|
||||
}
|
||||
|
||||
// Wrap in row_to_json with subquery
|
||||
const subquery = sql`SELECT row_to_json(sub) FROM (${innerSelect}) sub`;
|
||||
|
||||
return sql`(${subquery})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build many-to-many field SQL (through junction table)
|
||||
*/
|
||||
private buildManyToManyField({
|
||||
nestedConfig,
|
||||
targetTable,
|
||||
junctionConfig,
|
||||
}: {
|
||||
nestedConfig: CTEConfig;
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
mode: CTEMode;
|
||||
junctionConfig?: {
|
||||
table: PgTable;
|
||||
fromField: string;
|
||||
toField: string;
|
||||
};
|
||||
}): SQL {
|
||||
const through = nestedConfig.through!;
|
||||
const junctionTable = junctionConfig?.table || through.table;
|
||||
const junctionTableName = getTableName(junctionTable);
|
||||
const targetTableName = getTableName(targetTable);
|
||||
|
||||
// Note: We don't use aliases in subqueries to avoid table reference issues in WHERE clauses
|
||||
let subquery = sql`SELECT json_agg(row_to_json(${sql.identifier(targetTableName)})`;
|
||||
|
||||
// Add ORDER BY
|
||||
if (nestedConfig.orderBy && nestedConfig.orderBy.length > 0) {
|
||||
subquery = sql`${subquery} ORDER BY ${sql.join(nestedConfig.orderBy, sql`, `)}`;
|
||||
}
|
||||
|
||||
subquery = sql`${subquery}) FROM ${sql.identifier(junctionTableName)}`;
|
||||
subquery = sql`${subquery} INNER JOIN ${sql.identifier(targetTableName)} ON ${through.to}`;
|
||||
subquery = sql`${subquery} WHERE ${through.from}`;
|
||||
|
||||
// Add additional WHERE filters
|
||||
if (nestedConfig.where) {
|
||||
subquery = sql`${subquery} AND ${nestedConfig.where}`;
|
||||
}
|
||||
|
||||
// Add LIMIT
|
||||
if (nestedConfig.limit !== undefined) {
|
||||
subquery = sql`${subquery} LIMIT ${sql.raw(String(nestedConfig.limit))}`;
|
||||
}
|
||||
|
||||
// Wrap with COALESCE
|
||||
return sql`COALESCE((${subquery}), '[]'::json)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source table (unwrap if it's a CTE)
|
||||
*/
|
||||
private getSourceTable(from?: PgTable | CTEBuilder): PgTable {
|
||||
const source = from || this.config.from;
|
||||
if (source instanceof CTEBuilder) {
|
||||
return this.getSourceTable(source.config.from);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all CTE dependencies in correct order
|
||||
*/
|
||||
collectDependencies(): CTEBuilder[] {
|
||||
const deps = new Set<CTEBuilder>();
|
||||
const visited = new Set<CTEBuilder>();
|
||||
|
||||
const visit = (cte: CTEBuilder) => {
|
||||
if (visited.has(cte)) return;
|
||||
visited.add(cte);
|
||||
|
||||
for (const dep of cte.dependencies) {
|
||||
visit(dep);
|
||||
}
|
||||
|
||||
deps.add(cte);
|
||||
};
|
||||
|
||||
visit(this);
|
||||
return Array.from(deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the CTE query and return data with count
|
||||
*/
|
||||
async execute({
|
||||
db,
|
||||
}: CTEExecuteOptions): Promise<{ data: any[]; count: number }> {
|
||||
const allCTEs = this.collectDependencies();
|
||||
const cteDefinitions = allCTEs.map((cte) => cte.toSQL());
|
||||
|
||||
const query = sql`WITH ${sql.join(cteDefinitions, sql`, `)} SELECT * FROM ${sql.identifier(this.name)}`;
|
||||
|
||||
// Execute and extract rows and count - let errors propagate
|
||||
const result = await db.execute(query);
|
||||
const data = Array.isArray(result) ? result : result.rows || [];
|
||||
const count = result.count ?? data.length;
|
||||
|
||||
return { data, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique name for this CTE
|
||||
*/
|
||||
private generateName(): string {
|
||||
const table = this.getSourceTable();
|
||||
const tableName = getTableName(table);
|
||||
return `${tableName}_cte`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main CTE builder function
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const usersCTE = cte({
|
||||
* from: userTable,
|
||||
* with: {
|
||||
* organizations: cte({ from: organizations, ... })
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function cte(config: CTEConfig): CTEBuilder {
|
||||
return new CTEBuilder(config);
|
||||
}
|
||||
217
server/src/db/cteUtils/relationUtils.ts
Normal file
217
server/src/db/cteUtils/relationUtils.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { Many, One, Relations } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
|
||||
export interface RelationPath {
|
||||
type: "row" | "array";
|
||||
path: PgTable[];
|
||||
junction?: PgTable;
|
||||
}
|
||||
|
||||
export interface JunctionConfig {
|
||||
table: PgTable;
|
||||
fromField: string;
|
||||
toField: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the relationship path between two tables using Drizzle relations
|
||||
* Supports:
|
||||
* - Direct one() relationships → row
|
||||
* - Direct many() relationships → array
|
||||
* - Many-to-many through junction table → array
|
||||
*/
|
||||
export function findRelationPath({
|
||||
from,
|
||||
to,
|
||||
relations,
|
||||
}: {
|
||||
from: PgTable;
|
||||
to: PgTable;
|
||||
relations: Record<string, Relations>;
|
||||
}): RelationPath {
|
||||
const fromTableName = (from as any)[Symbol.for("drizzle:Name")] || from;
|
||||
const toTableName = (to as any)[Symbol.for("drizzle:Name")] || to;
|
||||
|
||||
// 1. Check for direct relationship
|
||||
const fromRelations = relations[fromTableName];
|
||||
if (fromRelations) {
|
||||
const directRel = Object.entries(fromRelations).find(([_, rel]) => {
|
||||
let relTable = (rel as any).referencedTable || (rel as any).table;
|
||||
// If it's a function, call it to get the actual table
|
||||
if (typeof relTable === "function") {
|
||||
relTable = relTable();
|
||||
}
|
||||
const relTableName = relTable?.[Symbol.for("drizzle:Name")] || relTable;
|
||||
return relTableName === toTableName;
|
||||
});
|
||||
|
||||
if (directRel) {
|
||||
const [_, rel] = directRel;
|
||||
// Check if it's a one() or many() relationship
|
||||
const isOne = (rel as any).isOne !== undefined;
|
||||
const isMany = (rel as any).isMany !== undefined;
|
||||
|
||||
if (isOne) {
|
||||
return { type: "row", path: [from, to] };
|
||||
}
|
||||
if (isMany) {
|
||||
return { type: "array", path: [from, to] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for many-to-many (through junction table)
|
||||
const junctionPaths = findJunctionPaths({ from, to, relations });
|
||||
|
||||
if (junctionPaths.length === 1) {
|
||||
const path = junctionPaths[0];
|
||||
return {
|
||||
type: "array",
|
||||
path: path.path,
|
||||
junction: path.junction,
|
||||
};
|
||||
}
|
||||
|
||||
if (junctionPaths.length > 1) {
|
||||
const pathStrings = junctionPaths
|
||||
.map((p) => p.path.map((t) => getTableName(t)).join(" → "))
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Ambiguous relationship between ${getTableName(from)} and ${getTableName(to)}. Found multiple paths: [${pathStrings}]. Please specify 'through' explicitly.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. No path found
|
||||
throw new Error(
|
||||
`No relationship found between ${getTableName(from)} and ${getTableName(to)}. Please define the relationship in Drizzle relations or specify 'through' explicitly.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many-to-many paths: from -> junction (many from 'from', one from junction) -> to (one from junction)
|
||||
*/
|
||||
function findJunctionPaths({
|
||||
from,
|
||||
to,
|
||||
relations,
|
||||
}: {
|
||||
from: PgTable;
|
||||
to: PgTable;
|
||||
relations: Record<string, Relations>;
|
||||
}): Array<{ path: [PgTable, PgTable, PgTable]; junction: PgTable }> {
|
||||
const paths: Array<{
|
||||
path: [PgTable, PgTable, PgTable];
|
||||
junction: PgTable;
|
||||
}> = [];
|
||||
const fromTableName = getTableName(from);
|
||||
const toTableName = getTableName(to);
|
||||
|
||||
// Get all many() relationships from 'from' table
|
||||
const fromRelations = relations[fromTableName];
|
||||
if (!fromRelations) return paths;
|
||||
|
||||
for (const [_, rel] of Object.entries(fromRelations)) {
|
||||
const isMany = (rel as any).isMany !== undefined;
|
||||
if (!isMany) continue;
|
||||
|
||||
const junctionTable = (rel as any).referencedTable || (rel as any).table;
|
||||
const junctionTableName = getTableName(junctionTable);
|
||||
|
||||
// Check if junction table has a one() relationship to 'to' table
|
||||
const junctionRelations = relations[junctionTableName];
|
||||
if (!junctionRelations) continue;
|
||||
|
||||
// Look for a one() relationship from junction to 'to'
|
||||
const toRel = Object.entries(junctionRelations).find(([_, jRel]) => {
|
||||
const isOne = (jRel as any).isOne !== undefined;
|
||||
if (!isOne) return false;
|
||||
|
||||
const targetTable = (jRel as any).referencedTable || (jRel as any).table;
|
||||
const targetTableName = getTableName(targetTable);
|
||||
return targetTableName === toTableName;
|
||||
});
|
||||
|
||||
if (toRel) {
|
||||
// Also verify there's a one() relationship from junction back to 'from'
|
||||
const reverseRel = Object.entries(junctionRelations).find(([_, jRel]) => {
|
||||
const isOne = (jRel as any).isOne !== undefined;
|
||||
if (!isOne) return false;
|
||||
|
||||
const targetTable =
|
||||
(jRel as any).referencedTable || (jRel as any).table;
|
||||
const targetTableName = getTableName(targetTable);
|
||||
return targetTableName === fromTableName;
|
||||
});
|
||||
|
||||
if (reverseRel) {
|
||||
paths.push({
|
||||
path: [from, junctionTable, to],
|
||||
junction: junctionTable,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table name from Drizzle table object
|
||||
*/
|
||||
function getTableName(table: PgTable): string {
|
||||
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract join field information from a junction table relationship
|
||||
*/
|
||||
export function getJunctionFields({
|
||||
junction,
|
||||
from,
|
||||
to,
|
||||
relations,
|
||||
}: {
|
||||
junction: PgTable;
|
||||
from: PgTable;
|
||||
to: PgTable;
|
||||
relations: Record<string, Relations>;
|
||||
}): JunctionConfig {
|
||||
const junctionTableName = getTableName(junction);
|
||||
const fromTableName = getTableName(from);
|
||||
const toTableName = getTableName(to);
|
||||
|
||||
const junctionRelations = relations[junctionTableName];
|
||||
if (!junctionRelations) {
|
||||
throw new Error(
|
||||
`No relations defined for junction table ${junctionTableName}`,
|
||||
);
|
||||
}
|
||||
|
||||
let fromField = "";
|
||||
let toField = "";
|
||||
|
||||
for (const [_, rel] of Object.entries(junctionRelations)) {
|
||||
const isOne = (rel as any).isOne !== undefined;
|
||||
if (!isOne) continue;
|
||||
|
||||
const targetTable = (rel as any).referencedTable || (rel as any).table;
|
||||
const targetTableName = getTableName(targetTable);
|
||||
const fields = (rel as any).fields || [];
|
||||
|
||||
if (targetTableName === fromTableName && fields.length > 0) {
|
||||
fromField = fields[0].name;
|
||||
}
|
||||
|
||||
if (targetTableName === toTableName && fields.length > 0) {
|
||||
toField = fields[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fromField || !toField) {
|
||||
throw new Error(
|
||||
`Could not determine junction fields for ${junctionTableName} between ${fromTableName} and ${toTableName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { table: junction, fromField, toField };
|
||||
}
|
||||
148
server/src/db/cteUtils/sqlGenerators.ts
Normal file
148
server/src/db/cteUtils/sqlGenerators.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
|
||||
export interface ArrayAggregationConfig {
|
||||
table: PgTable;
|
||||
alias?: string;
|
||||
filter?: SQL;
|
||||
orderBy?: SQL[];
|
||||
limit?: number;
|
||||
distinct?: boolean;
|
||||
}
|
||||
|
||||
export interface RowSubqueryConfig {
|
||||
table: PgTable;
|
||||
alias?: string;
|
||||
where?: SQL;
|
||||
}
|
||||
|
||||
export interface JunctionJoinConfig {
|
||||
junctionTable: PgTable;
|
||||
fromField: string;
|
||||
toField: string;
|
||||
fromTable: PgTable;
|
||||
toTable: PgTable;
|
||||
fromId: SQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate json_agg SQL for array aggregation with COALESCE to empty array
|
||||
* Example: COALESCE(json_agg(row_to_json(e) ORDER BY e.id) FILTER (WHERE e.id IS NOT NULL), '[]'::json)
|
||||
*/
|
||||
export function generateArrayAggSQL({
|
||||
table,
|
||||
alias,
|
||||
filter,
|
||||
orderBy,
|
||||
limit,
|
||||
distinct = false,
|
||||
}: ArrayAggregationConfig): SQL {
|
||||
const tableAlias = alias || getTableAlias(table);
|
||||
const distinctKeyword = distinct ? sql`DISTINCT ` : sql``;
|
||||
|
||||
let aggExpression = sql`json_agg(${distinctKeyword}row_to_json(${sql.identifier(tableAlias)})`;
|
||||
|
||||
// Add ORDER BY if provided
|
||||
if (orderBy && orderBy.length > 0) {
|
||||
aggExpression = sql`${aggExpression} ORDER BY ${sql.join(orderBy, sql`, `)}`;
|
||||
}
|
||||
|
||||
aggExpression = sql`${aggExpression})`;
|
||||
|
||||
// Add FILTER clause if provided
|
||||
if (filter) {
|
||||
aggExpression = sql`${aggExpression} FILTER (WHERE ${filter})`;
|
||||
}
|
||||
|
||||
// Wrap with COALESCE to handle NULL → empty array
|
||||
return sql`COALESCE(${aggExpression}, '[]'::json)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate row_to_json SQL for single row subquery
|
||||
* Example: (SELECT row_to_json(p) FROM products p WHERE p.id = ${parentId})
|
||||
*/
|
||||
export function generateRowSubquerySQL({
|
||||
table,
|
||||
alias,
|
||||
where,
|
||||
}: RowSubqueryConfig): SQL {
|
||||
const tableAlias = alias || getTableAlias(table);
|
||||
const tableName = getTableName(table);
|
||||
|
||||
let query = sql`(SELECT row_to_json(${sql.identifier(tableAlias)}) FROM ${sql.identifier(tableName)} ${sql.identifier(tableAlias)}`;
|
||||
|
||||
if (where) {
|
||||
query = sql`${query} WHERE ${where}`;
|
||||
}
|
||||
|
||||
query = sql`${query})`;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for many-to-many join through junction table
|
||||
* Example:
|
||||
* SELECT json_agg(o)
|
||||
* FROM member m
|
||||
* INNER JOIN organizations o ON o.id = m.organization_id
|
||||
* WHERE m.user_id = ${userId}
|
||||
*/
|
||||
export function generateJunctionJoinSQL({
|
||||
junctionTable,
|
||||
fromField,
|
||||
toField,
|
||||
fromTable,
|
||||
toTable,
|
||||
fromId,
|
||||
}: JunctionJoinConfig): SQL {
|
||||
const junctionAlias = getTableAlias(junctionTable);
|
||||
const toAlias = getTableAlias(toTable);
|
||||
const junctionTableName = getTableName(junctionTable);
|
||||
const toTableName = getTableName(toTable);
|
||||
|
||||
return sql`
|
||||
FROM ${sql.identifier(junctionTableName)} ${sql.identifier(junctionAlias)}
|
||||
INNER JOIN ${sql.identifier(toTableName)} ${sql.identifier(toAlias)}
|
||||
ON ${sql.identifier(toAlias)}.${sql.identifier("id")} = ${sql.identifier(junctionAlias)}.${sql.identifier(toField)}
|
||||
WHERE ${sql.identifier(junctionAlias)}.${sql.identifier(fromField)} = ${fromId}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for limiting results per parent using window functions
|
||||
* Example: row_number() OVER (PARTITION BY user_id ORDER BY created_at)
|
||||
*/
|
||||
export function generateRowNumberSQL({
|
||||
partitionBy,
|
||||
orderBy,
|
||||
}: {
|
||||
partitionBy: SQL;
|
||||
orderBy?: SQL[];
|
||||
}): SQL {
|
||||
let windowSQL = sql`row_number() OVER (PARTITION BY ${partitionBy}`;
|
||||
|
||||
if (orderBy && orderBy.length > 0) {
|
||||
windowSQL = sql`${windowSQL} ORDER BY ${sql.join(orderBy, sql`, `)}`;
|
||||
}
|
||||
|
||||
windowSQL = sql`${windowSQL})`;
|
||||
|
||||
return windowSQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table name from Drizzle table object
|
||||
*/
|
||||
function getTableName(table: PgTable): string {
|
||||
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short alias for a table (first letter of table name)
|
||||
*/
|
||||
function getTableAlias(table: PgTable): string {
|
||||
const tableName = getTableName(table);
|
||||
return tableName.charAt(0);
|
||||
}
|
||||
313
server/src/db/cteUtils/strategies/joinGroupByStrategy.ts
Normal file
313
server/src/db/cteUtils/strategies/joinGroupByStrategy.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
import type { CTEConfig } from "../buildCte.js";
|
||||
import {
|
||||
buildRelationGraph,
|
||||
getTableName,
|
||||
parseJoinCondition,
|
||||
type RelationNode,
|
||||
} from "./relationGraph.js";
|
||||
|
||||
/**
|
||||
* Build the optimized query using JOIN + GROUP BY strategy
|
||||
* This replaces correlated subqueries with flat JOINs for better performance
|
||||
*/
|
||||
export function buildJoinGroupByQuery({
|
||||
config,
|
||||
relations,
|
||||
extractJoinCondition,
|
||||
}: {
|
||||
config: CTEConfig;
|
||||
relations: Record<string, any>;
|
||||
extractJoinCondition: (params: {
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
fieldName: string;
|
||||
}) => SQL | undefined;
|
||||
}): SQL {
|
||||
// Build relation graph
|
||||
const rootTable = getSourceTable(config.from);
|
||||
const graph = buildRelationGraph({
|
||||
config,
|
||||
relations,
|
||||
extractJoinCondition,
|
||||
});
|
||||
|
||||
// Step 1: Build aggregation CTEs for array (one-to-many) relations
|
||||
const aggregationCTEs = buildAggregationCTEs({ graph, rootTable });
|
||||
|
||||
// Step 2: Build main query with row (one-to-one) relations as direct JOINs
|
||||
const mainQuery = buildMainQuery({ graph, config });
|
||||
|
||||
// Step 3: Assemble final query with CTEs if needed
|
||||
if (aggregationCTEs.length > 0) {
|
||||
const cteDefinitions = aggregationCTEs.map((c) => c.definition);
|
||||
return sql`WITH ${sql.join(cteDefinitions, sql`, `)} ${mainQuery}`;
|
||||
}
|
||||
|
||||
return mainQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the main SELECT query with direct JOINs for row relations
|
||||
*/
|
||||
function buildMainQuery({
|
||||
graph,
|
||||
config,
|
||||
}: {
|
||||
graph: RelationNode;
|
||||
config: CTEConfig;
|
||||
}): SQL {
|
||||
const selectFields: SQL[] = [];
|
||||
const joins: SQL[] = [];
|
||||
const groupByFields: SQL[] = [];
|
||||
|
||||
// Add root table columns
|
||||
selectFields.push(sql`${sql.identifier(graph.tableName)}.*`);
|
||||
groupByFields.push(sql`${sql.identifier(graph.tableName)}.id`);
|
||||
|
||||
// Recursively add JOINs and SELECT fields for nested relations
|
||||
addNestedJoins({
|
||||
node: graph,
|
||||
parentTableName: graph.tableName,
|
||||
path: [],
|
||||
selectFields,
|
||||
joins,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
// Build the base query
|
||||
let query = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(graph.tableName)}`;
|
||||
|
||||
// Add JOINs
|
||||
if (joins.length > 0) {
|
||||
query = sql`${query} ${sql.join(joins, sql` `)}`;
|
||||
}
|
||||
|
||||
// Add WHERE clause
|
||||
if (config.where) {
|
||||
query = sql`${query} WHERE ${config.where}`;
|
||||
}
|
||||
|
||||
// Add GROUP BY (needed when we have array aggregations)
|
||||
if (groupByFields.length > 1) {
|
||||
query = sql`${query} GROUP BY ${sql.join(groupByFields, sql`, `)}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
if (config.orderBy && config.orderBy.length > 0) {
|
||||
query = sql`${query} ORDER BY ${sql.join(config.orderBy, sql`, `)}`;
|
||||
}
|
||||
|
||||
// Add LIMIT
|
||||
if (config.limit !== undefined) {
|
||||
query = sql`${query} LIMIT ${sql.raw(String(config.limit))}`;
|
||||
}
|
||||
|
||||
// Add OFFSET
|
||||
if (config.offset !== undefined) {
|
||||
query = sql`${query} OFFSET ${sql.raw(String(config.offset))}`;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add JOINs for nested relations
|
||||
*/
|
||||
function addNestedJoins({
|
||||
node,
|
||||
parentTableName,
|
||||
path,
|
||||
selectFields,
|
||||
joins,
|
||||
groupByFields,
|
||||
}: {
|
||||
node: RelationNode;
|
||||
parentTableName: string;
|
||||
path: string[];
|
||||
selectFields: SQL[];
|
||||
joins: SQL[];
|
||||
groupByFields: SQL[];
|
||||
}) {
|
||||
for (const [fieldName, childNode] of Object.entries(node.nestedFields)) {
|
||||
const childAlias = [...path, fieldName].join("_");
|
||||
|
||||
if (childNode.mode === "row") {
|
||||
// For row relations (one-to-one), add direct JOIN
|
||||
joins.push(sql`
|
||||
LEFT JOIN ${sql.identifier(childNode.tableName)} AS ${sql.identifier(childAlias)}
|
||||
ON ${sql.identifier(childAlias)}.${sql.identifier(childNode.childKey || "id")} = ${sql.identifier(parentTableName)}.${sql.identifier(childNode.parentKey || "id")}
|
||||
`);
|
||||
|
||||
// Add to GROUP BY
|
||||
groupByFields.push(
|
||||
sql`${sql.identifier(childAlias)}.${sql.identifier(childNode.childKey || "id")}`,
|
||||
);
|
||||
|
||||
// Add to SELECT as row_to_json
|
||||
selectFields.push(
|
||||
sql`row_to_json(${sql.identifier(childAlias)}) AS ${sql.identifier(fieldName)}`,
|
||||
);
|
||||
|
||||
// Recurse for nested row relations
|
||||
addNestedJoins({
|
||||
node: childNode,
|
||||
parentTableName: childAlias,
|
||||
path: [...path, fieldName],
|
||||
selectFields,
|
||||
joins,
|
||||
groupByFields,
|
||||
});
|
||||
} else {
|
||||
// For array relations, we'll use aggregation CTEs
|
||||
// Add the aggregated field from the CTE
|
||||
const aggCTEName = [...path, fieldName, "agg"].join("_");
|
||||
selectFields.push(
|
||||
sql`COALESCE(${sql.identifier(aggCTEName)}.${sql.identifier(fieldName)}, '[]'::json) AS ${sql.identifier(fieldName)}`,
|
||||
);
|
||||
|
||||
// The CTE will be built separately and joined
|
||||
joins.push(sql`
|
||||
LEFT JOIN ${sql.identifier(aggCTEName)}
|
||||
ON ${sql.identifier(aggCTEName)}.group_key = ${sql.identifier(parentTableName)}.id
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build aggregation CTEs for array (one-to-many) relations
|
||||
*/
|
||||
function buildAggregationCTEs({
|
||||
graph,
|
||||
rootTable,
|
||||
}: {
|
||||
graph: RelationNode;
|
||||
rootTable: PgTable;
|
||||
}): Array<{ name: string; definition: SQL }> {
|
||||
const ctes: Array<{ name: string; definition: SQL }> = [];
|
||||
|
||||
function processNode({
|
||||
node,
|
||||
parentTableName,
|
||||
path,
|
||||
}: {
|
||||
node: RelationNode;
|
||||
parentTableName: string;
|
||||
path: string[];
|
||||
}) {
|
||||
for (const [fieldName, childNode] of Object.entries(node.nestedFields)) {
|
||||
if (childNode.mode === "array") {
|
||||
const cteName = [...path, fieldName, "agg"].join("_");
|
||||
|
||||
// Build SELECT fields for the aggregation
|
||||
const selectFields: SQL[] = [
|
||||
sql`${sql.identifier(childNode.tableName)}.*`,
|
||||
];
|
||||
|
||||
// Add nested row relations as inline row_to_json
|
||||
for (const [nestedFieldName, nestedNode] of Object.entries(
|
||||
childNode.nestedFields,
|
||||
)) {
|
||||
if (nestedNode.mode === "row") {
|
||||
selectFields.push(
|
||||
sql`row_to_json(${sql.identifier(nestedFieldName)}) AS ${sql.identifier(nestedFieldName)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build JOINs for nested row relations
|
||||
const nestedJoins: SQL[] = [];
|
||||
for (const [nestedFieldName, nestedNode] of Object.entries(
|
||||
childNode.nestedFields,
|
||||
)) {
|
||||
if (nestedNode.mode === "row") {
|
||||
nestedJoins.push(sql`
|
||||
LEFT JOIN ${sql.identifier(nestedNode.tableName)} AS ${sql.identifier(nestedFieldName)}
|
||||
ON ${sql.identifier(nestedFieldName)}.${sql.identifier(nestedNode.childKey || "id")} = ${sql.identifier(childNode.tableName)}.${sql.identifier(nestedNode.parentKey || "id")}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the aggregation query
|
||||
let innerQuery = sql`
|
||||
SELECT
|
||||
${sql.identifier(childNode.tableName)}.${sql.identifier(childNode.parentKey || "id")} AS group_key,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
row_to_json(agg_sub)
|
||||
ORDER BY ${sql.identifier(childNode.tableName)}.created_at DESC
|
||||
),
|
||||
'[]'::json
|
||||
) AS ${sql.identifier(fieldName)}
|
||||
FROM ${sql.identifier(childNode.tableName)}
|
||||
`;
|
||||
|
||||
// Add nested JOINs
|
||||
if (nestedJoins.length > 0) {
|
||||
innerQuery = sql`${innerQuery} ${sql.join(nestedJoins, sql` `)}`;
|
||||
}
|
||||
|
||||
// Add WHERE to filter by parent IDs
|
||||
innerQuery = sql`${innerQuery}
|
||||
WHERE ${sql.identifier(childNode.tableName)}.${sql.identifier(childNode.parentKey || "id")} IN (
|
||||
SELECT id FROM ${sql.identifier(parentTableName)}
|
||||
)
|
||||
`;
|
||||
|
||||
// Add additional filters from config
|
||||
if (childNode.config.where) {
|
||||
innerQuery = sql`${innerQuery} AND ${childNode.config.where}`;
|
||||
}
|
||||
|
||||
// Add GROUP BY
|
||||
innerQuery = sql`${innerQuery}
|
||||
GROUP BY ${sql.identifier(childNode.tableName)}.${sql.identifier(childNode.parentKey || "id")}
|
||||
`;
|
||||
|
||||
// Wrap in CTE
|
||||
const cteDefinition = sql`${sql.identifier(cteName)} AS (
|
||||
${innerQuery}
|
||||
)`;
|
||||
|
||||
ctes.push({
|
||||
name: cteName,
|
||||
definition: cteDefinition,
|
||||
});
|
||||
|
||||
// Recurse for nested array relations
|
||||
processNode({
|
||||
node: childNode,
|
||||
parentTableName: childNode.tableName,
|
||||
path: [...path, fieldName],
|
||||
});
|
||||
} else {
|
||||
// For row relations, continue recursion
|
||||
processNode({
|
||||
node: childNode,
|
||||
parentTableName: childNode.tableName,
|
||||
path: [...path, fieldName],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processNode({
|
||||
node: graph,
|
||||
parentTableName: graph.tableName,
|
||||
path: [],
|
||||
});
|
||||
|
||||
return ctes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source table from config (unwrap CTEBuilder if needed)
|
||||
*/
|
||||
function getSourceTable(from: any): PgTable {
|
||||
if (from?.config?.from) {
|
||||
return getSourceTable(from.config.from);
|
||||
}
|
||||
return from;
|
||||
}
|
||||
159
server/src/db/cteUtils/strategies/relationGraph.ts
Normal file
159
server/src/db/cteUtils/strategies/relationGraph.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
import type { CTEConfig } from "../buildCte.js";
|
||||
import { CTEBuilder } from "../buildCte.js";
|
||||
import { type CTEMode, inferMode } from "../typeDetection.js";
|
||||
|
||||
/**
|
||||
* Get table name from Drizzle table object
|
||||
*/
|
||||
export function getTableName(table: PgTable): string {
|
||||
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a node in the relation graph
|
||||
*/
|
||||
export interface RelationNode {
|
||||
table: PgTable;
|
||||
tableName: string;
|
||||
parentKey?: string; // Foreign key column on child table
|
||||
childKey?: string; // Primary key column on parent table
|
||||
mode: CTEMode;
|
||||
nestedFields: Record<string, RelationNode>;
|
||||
config: CTEConfig; // Store original config for filters/ordering
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse join condition SQL to extract column names
|
||||
*/
|
||||
export function parseJoinCondition({
|
||||
joinCondition,
|
||||
parentTableName,
|
||||
targetTableName,
|
||||
}: {
|
||||
joinCondition?: SQL;
|
||||
parentTableName: string;
|
||||
targetTableName: string;
|
||||
}): { parentKey: string; childKey: string } {
|
||||
if (!joinCondition) {
|
||||
// Default fallback - assume standard foreign key pattern
|
||||
return {
|
||||
parentKey: "id",
|
||||
childKey: `internal_${parentTableName.replace(/s$/, "")}_id`,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract column names from SQL
|
||||
// Expected format: "target_table.child_key = parent_table.parent_key"
|
||||
try {
|
||||
const sqlString = joinCondition.getSQL().sql;
|
||||
|
||||
// Try to parse pattern: table.column = table.column
|
||||
const match = sqlString.match(/(\w+)\.(\w+)\s*=\s*(\w+)\.(\w+)/);
|
||||
|
||||
if (match) {
|
||||
const [_, table1, col1, _table2, col2] = match;
|
||||
|
||||
// Determine which is parent and which is child
|
||||
if (table1 === targetTableName) {
|
||||
return { parentKey: col2, childKey: col1 };
|
||||
}
|
||||
return { parentKey: col1, childKey: col2 };
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, use fallback
|
||||
console.warn("Failed to parse join condition:", error);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
parentKey: "id",
|
||||
childKey: `internal_${parentTableName.replace(/s$/, "")}_id`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build relation graph from CTE config
|
||||
* This maps out the entire relationship tree
|
||||
*/
|
||||
export function buildRelationGraph({
|
||||
config,
|
||||
parentTable,
|
||||
relations,
|
||||
extractJoinCondition,
|
||||
}: {
|
||||
config: CTEConfig;
|
||||
parentTable?: PgTable;
|
||||
relations: Record<string, any>;
|
||||
extractJoinCondition: (params: {
|
||||
parentTable: PgTable;
|
||||
targetTable: PgTable;
|
||||
fieldName: string;
|
||||
}) => SQL | undefined;
|
||||
}): RelationNode {
|
||||
const table = getSourceTable(config.from);
|
||||
const tableName = getTableName(table);
|
||||
|
||||
const node: RelationNode = {
|
||||
table,
|
||||
tableName,
|
||||
mode: "row",
|
||||
nestedFields: {},
|
||||
config,
|
||||
};
|
||||
|
||||
if (!config.with) return node;
|
||||
|
||||
for (const [fieldName, nestedConfig] of Object.entries(config.with)) {
|
||||
const nested =
|
||||
nestedConfig instanceof CTEBuilder ? nestedConfig.config : nestedConfig;
|
||||
const targetTable = getSourceTable(nested.from);
|
||||
const targetTableName = getTableName(targetTable);
|
||||
|
||||
// Extract join keys from relations
|
||||
const joinCondition = extractJoinCondition({
|
||||
parentTable: table,
|
||||
targetTable,
|
||||
fieldName,
|
||||
});
|
||||
|
||||
const { parentKey, childKey } = parseJoinCondition({
|
||||
joinCondition,
|
||||
parentTableName: tableName,
|
||||
targetTableName,
|
||||
});
|
||||
|
||||
// Recursively build nested nodes
|
||||
const nestedNode = buildRelationGraph({
|
||||
config: nested,
|
||||
parentTable: table,
|
||||
relations,
|
||||
extractJoinCondition,
|
||||
});
|
||||
|
||||
nestedNode.parentKey = parentKey;
|
||||
nestedNode.childKey = childKey;
|
||||
nestedNode.mode = inferMode({
|
||||
fieldName,
|
||||
limit: nested.limit,
|
||||
orderBy: nested.orderBy,
|
||||
through: nested.through,
|
||||
mode: nested.mode,
|
||||
});
|
||||
|
||||
node.nestedFields[fieldName] = nestedNode;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source table from config (unwrap CTEBuilder if needed)
|
||||
*/
|
||||
function getSourceTable(from: PgTable | CTEBuilder): PgTable {
|
||||
if (from instanceof CTEBuilder) {
|
||||
return getSourceTable(from.config.from);
|
||||
}
|
||||
return from;
|
||||
}
|
||||
79
server/src/db/cteUtils/strategies/strategySelector.ts
Normal file
79
server/src/db/cteUtils/strategies/strategySelector.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { CTEConfig } from "../buildCte.js";
|
||||
import { CTEBuilder } from "../buildCte.js";
|
||||
import { inferMode } from "../typeDetection.js";
|
||||
|
||||
export type QueryStrategy = "correlated" | "join_group_by";
|
||||
|
||||
/**
|
||||
* Calculate the maximum nesting depth of a CTE configuration
|
||||
*/
|
||||
export function calculateNestingDepth({
|
||||
config,
|
||||
current = 0,
|
||||
}: {
|
||||
config: CTEConfig;
|
||||
current?: number;
|
||||
}): number {
|
||||
if (!config.with) return current;
|
||||
|
||||
let maxDepth = current;
|
||||
for (const nested of Object.values(config.with)) {
|
||||
const nestedConfig = nested instanceof CTEBuilder ? nested.config : nested;
|
||||
const depth = calculateNestingDepth({
|
||||
config: nestedConfig,
|
||||
current: current + 1,
|
||||
});
|
||||
maxDepth = Math.max(maxDepth, depth);
|
||||
}
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if any nested relations will return arrays (one-to-many)
|
||||
*/
|
||||
export function detectArrayRelations({
|
||||
config,
|
||||
}: {
|
||||
config: CTEConfig;
|
||||
}): boolean {
|
||||
if (!config.with) return false;
|
||||
|
||||
for (const [fieldName, nested] of Object.entries(config.with)) {
|
||||
const nestedConfig = nested instanceof CTEBuilder ? nested.config : nested;
|
||||
const mode = inferMode({
|
||||
fieldName,
|
||||
limit: nestedConfig.limit,
|
||||
orderBy: nestedConfig.orderBy,
|
||||
through: nestedConfig.through,
|
||||
mode: nestedConfig.mode,
|
||||
});
|
||||
|
||||
if (mode === "array") return true;
|
||||
if (detectArrayRelations({ config: nestedConfig })) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which query strategy to use based on config complexity
|
||||
*/
|
||||
export function shouldUseJoinStrategy({
|
||||
config,
|
||||
}: {
|
||||
config: CTEConfig;
|
||||
}): boolean {
|
||||
// 1. Explicit strategy override
|
||||
if ((config as any).strategy === "join_group_by") return true;
|
||||
if ((config as any).strategy === "correlated") return false;
|
||||
|
||||
// 2. Auto detection - TEMPORARILY DISABLED while we fix join condition parsing
|
||||
// const depth = calculateNestingDepth({ config });
|
||||
// const hasArrayRelations = detectArrayRelations({ config });
|
||||
// const isLargeResultSet = config.limit === undefined || config.limit > 50;
|
||||
|
||||
// Use JOIN strategy when:
|
||||
// - Deep nesting (2+ levels) with array relations and large result sets
|
||||
// - Or very deep nesting (3+ levels) regardless of result set size
|
||||
// return (depth >= 2 && hasArrayRelations && isLargeResultSet) || depth >= 3;
|
||||
return false; // Temporarily disabled
|
||||
}
|
||||
49
server/src/db/cteUtils/typeDetection.ts
Normal file
49
server/src/db/cteUtils/typeDetection.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
|
||||
export type CTEMode = "array" | "row";
|
||||
|
||||
export interface ModeDetectionConfig {
|
||||
fieldName?: string;
|
||||
limit?: number;
|
||||
orderBy?: SQL[];
|
||||
through?: unknown;
|
||||
mode?: CTEMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer whether a CTE should return an array or single row based on config and heuristics
|
||||
*/
|
||||
export function inferMode(config: ModeDetectionConfig): CTEMode {
|
||||
// 1. Explicit mode always wins
|
||||
if (config.mode) {
|
||||
return config.mode;
|
||||
}
|
||||
|
||||
// 2. Has `through`? → array (many-to-many relationship)
|
||||
if (config.through) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
// 3. Has limit > 1? → array
|
||||
if (config.limit !== undefined && config.limit !== 1) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
// 4. Has orderBy? → probably array (ordering implies multiple results)
|
||||
if (config.orderBy && config.orderBy.length > 0) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
// 5. Plural field name? → array (entities, organizations, products)
|
||||
// Exclude words ending in 'ss' (address, process, etc.)
|
||||
if (
|
||||
config.fieldName &&
|
||||
config.fieldName.endsWith("s") &&
|
||||
!config.fieldName.endsWith("ss")
|
||||
) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
// 6. Default: row (safer default for 1:1 relationships)
|
||||
return "row";
|
||||
}
|
||||
@@ -81,6 +81,8 @@ const createDevLogStream = () => {
|
||||
"context",
|
||||
"req",
|
||||
"data",
|
||||
"body",
|
||||
"query",
|
||||
];
|
||||
const additionalFields = Object.keys(log)
|
||||
.filter((key) => !excludeFields.includes(key))
|
||||
@@ -90,7 +92,7 @@ const createDevLogStream = () => {
|
||||
}, {} as any);
|
||||
|
||||
if (Object.keys(additionalFields).length > 0) {
|
||||
message += " " + JSON.stringify(additionalFields, null, 2);
|
||||
message += ` ${JSON.stringify(additionalFields, null, 2)}`;
|
||||
}
|
||||
|
||||
// Format the final log line
|
||||
@@ -98,7 +100,7 @@ const createDevLogStream = () => {
|
||||
|
||||
process.stdout.write(formattedLog);
|
||||
callback();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Fallback for malformed JSON
|
||||
process.stdout.write(chunk);
|
||||
callback();
|
||||
|
||||
@@ -1,39 +1,34 @@
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
|
||||
import {
|
||||
AppEnv,
|
||||
type AppEnv,
|
||||
BillingType,
|
||||
CusProductStatus,
|
||||
Customer,
|
||||
FullCusProduct,
|
||||
FullCustomerEntitlement,
|
||||
FullCustomerPrice,
|
||||
Organization,
|
||||
type Customer,
|
||||
type FullCusProduct,
|
||||
type FullCustomerEntitlement,
|
||||
type FullCustomerPrice,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { createStripeCli } from "../../utils.js";
|
||||
import { getStripeSubs } from "../../stripeSubUtils.js";
|
||||
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
|
||||
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { EntityService } from "@/internal/api/entities/EntityService.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
|
||||
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { getFeatureName } from "@/internal/features/utils/displayUtils.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import {
|
||||
getFullStripeInvoice,
|
||||
invoiceToSubId,
|
||||
} from "../../stripeInvoiceUtils.js";
|
||||
import { handleUsagePrices } from "./handleUsagePrices.js";
|
||||
import { handleContUsePrices } from "./handleContUsePrices.js";
|
||||
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import { handlePrepaidPrices } from "./handlePrepaidPrices.js";
|
||||
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
|
||||
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
|
||||
import { getStripeSubs } from "../../stripeSubUtils.js";
|
||||
import { createStripeCli } from "../../utils.js";
|
||||
import { handleContUsePrices } from "./handleContUsePrices.js";
|
||||
import { handlePrepaidPrices } from "./handlePrepaidPrices.js";
|
||||
import { handleUsagePrices } from "./handleUsagePrices.js";
|
||||
|
||||
const handleInArrearProrated = async ({
|
||||
db,
|
||||
@@ -82,19 +77,19 @@ const handleInArrearProrated = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
let feature = cusEnt.entitlement.feature;
|
||||
const feature = cusEnt.entitlement.feature;
|
||||
logger.info(
|
||||
`Handling invoice.created for in arrear prorated, feature: ${feature.id}`,
|
||||
);
|
||||
|
||||
let deletedEntities = await EntityService.list({
|
||||
const deletedEntities = await EntityService.list({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id!,
|
||||
inFeatureIds: [feature.internal_id!],
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
if (deletedEntities.length == 0) {
|
||||
if (deletedEntities.length === 0) {
|
||||
logger.info("No deleted entities found");
|
||||
return;
|
||||
}
|
||||
@@ -112,7 +107,7 @@ const handleInArrearProrated = async ({
|
||||
|
||||
for (const linkedCusEnt of cusEnts) {
|
||||
// isLinked
|
||||
let isLinked = linkedCusEnt.entitlement.entity_feature_id == feature.id;
|
||||
const isLinked = linkedCusEnt.entitlement.entity_feature_id === feature.id;
|
||||
|
||||
if (!isLinked) {
|
||||
continue;
|
||||
@@ -123,14 +118,14 @@ const handleInArrearProrated = async ({
|
||||
);
|
||||
|
||||
// Delete cus ent ids
|
||||
let newEntities = structuredClone(linkedCusEnt.entities!);
|
||||
const newEntities = structuredClone(linkedCusEnt.entities!);
|
||||
for (const entityId in newEntities) {
|
||||
if (deletedEntities.some((e) => e.id == entityId)) {
|
||||
if (deletedEntities.some((e) => e.id === entityId)) {
|
||||
delete newEntities[entityId];
|
||||
}
|
||||
}
|
||||
|
||||
let updated = await CusEntService.update({
|
||||
const updated = await CusEntService.update({
|
||||
db,
|
||||
id: linkedCusEnt.id,
|
||||
updates: {
|
||||
@@ -198,13 +193,13 @@ export const sendUsageAndReset = async ({
|
||||
|
||||
for (const cusPrice of cusPrices) {
|
||||
const price = cusPrice.price;
|
||||
let billingType = getBillingType(price.config);
|
||||
const billingType = getBillingType(price.config);
|
||||
|
||||
if (isFixedPrice({ price })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relatedCusEnt = getRelatedCusEnt({
|
||||
const relatedCusEnt = getRelatedCusEnt({
|
||||
cusPrice,
|
||||
cusEnts,
|
||||
});
|
||||
@@ -227,19 +222,19 @@ export const sendUsageAndReset = async ({
|
||||
|
||||
const subId = invoiceToSubId({ invoice });
|
||||
|
||||
if (!usageBasedSub || usageBasedSub.id != subId) {
|
||||
if (!usageBasedSub || usageBasedSub.id !== subId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If trial just ended, skip
|
||||
const { start, end } = subToPeriodStartEnd({ sub: usageBasedSub });
|
||||
|
||||
if (usageBasedSub.trial_end == start) {
|
||||
if (usageBasedSub.trial_end === start) {
|
||||
logger.info(`Trial just ended, skipping usage invoice.created`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (billingType == BillingType.UsageInArrear) {
|
||||
if (billingType === BillingType.UsageInArrear) {
|
||||
await handleUsagePrices({
|
||||
db,
|
||||
org,
|
||||
@@ -254,7 +249,7 @@ export const sendUsageAndReset = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (billingType == BillingType.InArrearProrated) {
|
||||
if (billingType === BillingType.InArrearProrated) {
|
||||
await handleContUsePrices({
|
||||
db,
|
||||
stripeCli,
|
||||
@@ -266,7 +261,7 @@ export const sendUsageAndReset = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (billingType == BillingType.UsageInAdvance) {
|
||||
if (billingType === BillingType.UsageInAdvance) {
|
||||
await handlePrepaidPrices({
|
||||
db,
|
||||
stripeCli,
|
||||
@@ -324,11 +319,11 @@ export const handleInvoiceCreated = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
let internalEntityId = activeProducts.find(
|
||||
const internalEntityId = activeProducts.find(
|
||||
(p) => p.internal_entity_id,
|
||||
)?.internal_entity_id;
|
||||
|
||||
let features = await FeatureService.list({
|
||||
const features = await FeatureService.list({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
@@ -372,7 +367,7 @@ export const handleInvoiceCreated = async ({
|
||||
|
||||
const stripeSubs = await getStripeSubs({
|
||||
stripeCli: createStripeCli({ org, env }),
|
||||
subIds: activeProducts.map((p) => p.subscription_ids || []).flat(),
|
||||
subIds: activeProducts.flatMap((p) => p.subscription_ids || []),
|
||||
});
|
||||
|
||||
for (const activeProduct of activeProducts) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApiVersion,
|
||||
AppEnv,
|
||||
type Customer,
|
||||
EntInterval,
|
||||
type FullCusProduct,
|
||||
@@ -20,6 +21,7 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js";
|
||||
import { getInvoiceItemForUsage } from "../../stripePriceUtils.js";
|
||||
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
|
||||
import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { getAllFullCustomers } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
|
||||
|
||||
export const handleUsagePrices = async ({
|
||||
db,
|
||||
@@ -134,6 +136,14 @@ export const handleUsagePrices = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
const allFullCustomers = await getAllFullCustomers({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env: AppEnv.Live,
|
||||
});
|
||||
|
||||
console.log(`All full customers: ${allFullCustomers.length}`);
|
||||
|
||||
const ent = relatedCusEnt.entitlement;
|
||||
|
||||
const resetBalancesUpdate = getResetBalancesUpdate({
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AppEnv, InvoiceStatus } from "@autumn/shared";
|
||||
import {
|
||||
type AppEnv,
|
||||
type Invoice,
|
||||
InvoiceStatus,
|
||||
stripeToAtmnAmount,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
@@ -32,7 +38,12 @@ const handleInvoiceCheckoutVoided = async ({
|
||||
id: metadataId,
|
||||
});
|
||||
|
||||
const { anchorToUnix, config, ...rest } = metadata?.data;
|
||||
const {
|
||||
anchorToUnix: _anchorToUnix,
|
||||
config: _config,
|
||||
...rest
|
||||
} = metadata?.data || {};
|
||||
|
||||
const attachParams = rest as AttachParams;
|
||||
|
||||
if (!attachParams) return;
|
||||
@@ -81,47 +92,46 @@ export const handleInvoiceUpdated = async ({
|
||||
req: any;
|
||||
}) => {
|
||||
const invoiceObject = event.data.object as Stripe.Invoice;
|
||||
const currentInvoice = await InvoiceService.getByStripeId({
|
||||
db: req.db,
|
||||
stripeId: invoiceObject.id!,
|
||||
});
|
||||
|
||||
// const invoice = await getFullStripeInvoice({
|
||||
// stripeCli,
|
||||
// stripeId: invoiceObject.id!,
|
||||
// });
|
||||
|
||||
const prevAttributes = event.data.previous_attributes as any;
|
||||
const invoiceVoided =
|
||||
prevAttributes?.status !== "void" && invoiceObject.status === "void";
|
||||
|
||||
const { logger } = req;
|
||||
const updates: Partial<Invoice> = {};
|
||||
|
||||
if (invoiceVoided) {
|
||||
logger.info(`Invoice has been voided!`);
|
||||
|
||||
await handleInvoiceCheckoutVoided({
|
||||
db: req.db,
|
||||
stripeCli,
|
||||
invoiceObject,
|
||||
logger,
|
||||
});
|
||||
|
||||
await InvoiceService.updateByStripeId({
|
||||
db: req.db,
|
||||
stripeId: invoiceObject.id!,
|
||||
updates: {
|
||||
status: InvoiceStatus.Void,
|
||||
},
|
||||
});
|
||||
if (invoiceObject.status === "void") {
|
||||
updates.status = InvoiceStatus.Void;
|
||||
}
|
||||
|
||||
const invoiceOpen =
|
||||
prevAttributes?.status !== "open" && invoiceObject.status === "open";
|
||||
if (invoiceObject.status === "open") {
|
||||
updates.status = InvoiceStatus.Open;
|
||||
}
|
||||
|
||||
if (invoiceOpen) {
|
||||
// logger.info(`Invoice has been opened!`);
|
||||
if (currentInvoice) {
|
||||
const newAtmnTotal = stripeToAtmnAmount({
|
||||
amount: invoiceObject.total,
|
||||
currency: invoiceObject.currency,
|
||||
});
|
||||
|
||||
const totalEquals = new Decimal(newAtmnTotal).eq(currentInvoice.total);
|
||||
|
||||
if (!totalEquals) {
|
||||
updates.total = newAtmnTotal;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0 && invoiceObject.id) {
|
||||
await InvoiceService.updateByStripeId({
|
||||
db: req.db,
|
||||
stripeId: invoiceObject.id!,
|
||||
updates: {
|
||||
status: InvoiceStatus.Open,
|
||||
},
|
||||
stripeId: invoiceObject.id,
|
||||
updates,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
126
server/src/honoMiddlewares/analyticsMiddleware.ts
Normal file
126
server/src/honoMiddlewares/analyticsMiddleware.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
|
||||
const parseCustomerIdFromUrl = ({
|
||||
url,
|
||||
}: {
|
||||
url: string;
|
||||
}): string | undefined => {
|
||||
if (!url.startsWith("/v1")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cleanUrl = url.split("?")[0].replace(/^\/+|\/+$/g, "");
|
||||
const segments = cleanUrl.split("/");
|
||||
const customersIndex = segments.indexOf("customers");
|
||||
|
||||
if (customersIndex !== -1 && segments[customersIndex + 1]) {
|
||||
return segments[customersIndex + 1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logs response details asynchronously without blocking
|
||||
*/
|
||||
const logResponse = async ({
|
||||
ctx,
|
||||
c,
|
||||
method,
|
||||
skipUrls,
|
||||
}: {
|
||||
ctx: any;
|
||||
c: Context<HonoEnv>;
|
||||
method: string;
|
||||
skipUrls: string[];
|
||||
}) => {
|
||||
try {
|
||||
// Skip logging for certain URLs
|
||||
if (skipUrls.includes(c.req.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to extract response body if it's JSON
|
||||
let responseBody: any = null;
|
||||
const contentType = c.res.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
try {
|
||||
// Clone response to read body without consuming it
|
||||
const clonedResponse = c.res.clone();
|
||||
responseBody = await clonedResponse.json();
|
||||
} catch (_error) {
|
||||
// Response might not be JSON or already consumed
|
||||
}
|
||||
}
|
||||
|
||||
// Log response in non-development environments
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
ctx.logger.info(
|
||||
`[${c.res.status}] ${method} ${c.req.path} (${ctx.org?.slug})`,
|
||||
{
|
||||
statusCode: c.res.status,
|
||||
res: responseBody,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log response to logtail");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytics middleware for Hono
|
||||
* Enriches logger context and logs responses
|
||||
*/
|
||||
export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
const ctx = c.get("ctx");
|
||||
const skipUrls = ["/v1/customers/all/search"];
|
||||
|
||||
// Parse request body for customer_id
|
||||
let requestBody: any = null;
|
||||
const method = c.req.method;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
// Clone the request to read body without consuming it
|
||||
requestBody = await c.req.json();
|
||||
} catch (_error) {
|
||||
// Body might not be JSON, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
const customerId =
|
||||
requestBody?.customer_id || parseCustomerIdFromUrl({ url: c.req.path });
|
||||
|
||||
// Enrich logger context
|
||||
const reqContext = {
|
||||
org_id: ctx.org?.id,
|
||||
org_slug: ctx.org?.slug,
|
||||
env: ctx.env,
|
||||
authType: ctx.authType,
|
||||
body: requestBody,
|
||||
customer_id: customerId,
|
||||
user_id: ctx.userId || null,
|
||||
};
|
||||
|
||||
// Update logger with enriched context
|
||||
ctx.logger = ctx.logger.child({
|
||||
context: {
|
||||
context: reqContext,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.logger.info(`${method} ${c.req.path} (${ctx.org?.slug})`);
|
||||
|
||||
// Execute the request
|
||||
await next();
|
||||
|
||||
// Log response asynchronously without blocking (runs after response is sent)
|
||||
Promise.resolve()
|
||||
.then(() => logResponse({ ctx, c, method, skipUrls }))
|
||||
.catch((error) => {
|
||||
console.error("Failed to log response to logtail");
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
@@ -61,7 +61,6 @@ export const apiVersionMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
|
||||
// Store in context - now you can do ctx.apiVersion.gte(ApiVersion.V1_1)
|
||||
ctx.apiVersion = finalVersion;
|
||||
console.log(`Autumn Version: ${finalVersion.semver}`);
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -30,10 +30,22 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
timestamp,
|
||||
};
|
||||
|
||||
const method = c.req.method;
|
||||
const path = c.req.path;
|
||||
|
||||
let body = null;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
// Create child logger
|
||||
const childLogger = logger.child({
|
||||
context: {
|
||||
req: reqContext,
|
||||
body,
|
||||
query: c.req.query(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,21 +70,7 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
env: AppEnv.Sandbox, // maybe use app_env headers
|
||||
});
|
||||
|
||||
const method = c.req.method;
|
||||
const path = c.req.path;
|
||||
|
||||
let body = null;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
logger.info(`[HONO] ${method} ${path}`, {
|
||||
context: {
|
||||
body,
|
||||
},
|
||||
});
|
||||
childLogger.info(`${method} ${path}`);
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getRequestListener } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { analyticsMiddleware } from "./honoMiddlewares/analyticsMiddleware.js";
|
||||
import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js";
|
||||
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
|
||||
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
|
||||
@@ -11,6 +12,7 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
|
||||
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
|
||||
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
|
||||
import { cusRouter } from "./internal/customers/cusRouter.js";
|
||||
import { honoPlatformRouter } from "./internal/platform/honoPlatformRouter.js";
|
||||
import { honoProductRouter } from "./internal/products/productRouter.js";
|
||||
import { auth } from "./utils/auth.js";
|
||||
|
||||
@@ -82,14 +84,15 @@ export const createHonoApp = () => {
|
||||
// Step 6: Refresh cache middleware - clears customer cache after successful mutations
|
||||
app.use("/v1/*", refreshCacheMiddleware);
|
||||
|
||||
// Step 7: Query middleware
|
||||
// Step 7: Analytics middleware - enriches logger context and logs responses
|
||||
app.use("/v1/*", analyticsMiddleware);
|
||||
|
||||
// Step 8: Query middleware - handles query parsing and validation
|
||||
app.use("/v1/*", queryMiddleware());
|
||||
|
||||
// Additional middleware can be added here as needed
|
||||
|
||||
// Routes
|
||||
app.route("v1/customers", cusRouter);
|
||||
app.route("v1/products", honoProductRouter);
|
||||
app.route("v1/platform", honoPlatformRouter);
|
||||
|
||||
// Error handler - must be defined after all routes and middleware
|
||||
app.onError(errorMiddleware);
|
||||
@@ -132,7 +135,7 @@ export const redirectToHono = () => {
|
||||
// Check for dynamic routes (e.g., /v1/products/:id)
|
||||
if (routePath.includes(":")) {
|
||||
const routeRegex = new RegExp(
|
||||
"^" + routePath.replace(/:[^/]+/g, "([^/]+)") + "$",
|
||||
`^${routePath.replace(/:[^/]+/g, "([^/]+)")}$`,
|
||||
);
|
||||
return routeRegex.test(path);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
type AttachConfig,
|
||||
AttachScenario,
|
||||
ErrCode,
|
||||
InternalError,
|
||||
SuccessCode,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js
|
||||
import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js";
|
||||
import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
|
||||
import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js";
|
||||
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
|
||||
export const handleScheduleFunction2 = async ({
|
||||
req,
|
||||
@@ -79,6 +80,14 @@ export const handleScheduleFunction2 = async ({
|
||||
const subItems = curSub?.items.data.filter((item) =>
|
||||
subItemInCusProduct({ cusProduct: curCusProduct, subItem: item }),
|
||||
);
|
||||
|
||||
if (subItems.length == 0) {
|
||||
logger.error(`SCHEDULE FLOW: subItems is empty, curCusProduct: ${curCusProduct.product.name}`);
|
||||
throw new InternalError({
|
||||
message: `SCHEDULE FLOW: subItems is empty, curCusProduct: ${curCusProduct.product.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedEnd = getLatestPeriodEnd({ subItems });
|
||||
|
||||
if (schedule) {
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
import {
|
||||
type AttachConfig,
|
||||
calculateProrationAmount,
|
||||
Feature,
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
cusProductToProduct,
|
||||
type Feature,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
getFeatureInvoiceDescription,
|
||||
OnDecrease,
|
||||
priceToInvoiceAmount,
|
||||
UsagePriceConfig,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { Stripe } from "stripe";
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { Stripe } from "stripe";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
|
||||
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import {
|
||||
shouldBillNow,
|
||||
shouldProrate,
|
||||
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
|
||||
|
||||
import { Decimal } from "decimal.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
|
||||
import { cusProductToProduct } from "@autumn/shared";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
|
||||
const onDecreaseToStripeProration: Record<OnDecrease, string> = {
|
||||
[OnDecrease.ProrateImmediately]: "always_invoice",
|
||||
[OnDecrease.ProrateNextCycle]: "create_prorations",
|
||||
[OnDecrease.Prorate]: "create_prorations",
|
||||
[OnDecrease.None]: "none",
|
||||
[OnDecrease.NoProrations]: "none",
|
||||
};
|
||||
|
||||
export const handleQuantityDowngrade = async ({
|
||||
req,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSub,
|
||||
oldOptions,
|
||||
@@ -47,13 +39,14 @@ export const handleQuantityDowngrade = async ({
|
||||
}: {
|
||||
req: any;
|
||||
attachParams: AttachParams;
|
||||
attachConfig: AttachConfig;
|
||||
cusProduct: FullCusProduct;
|
||||
stripeSub: Stripe.Subscription;
|
||||
oldOptions: FeatureOptions;
|
||||
newOptions: FeatureOptions;
|
||||
subItem: Stripe.SubscriptionItem;
|
||||
}) => {
|
||||
const { db, logger, org, features } = req;
|
||||
const { db, logger, org } = req;
|
||||
const { stripeCli, paymentMethod } = attachParams;
|
||||
|
||||
const cusPrice = featureToCusPrice({
|
||||
@@ -65,10 +58,6 @@ export const handleQuantityDowngrade = async ({
|
||||
cusPrice.price.proration_config?.on_decrease ||
|
||||
OnDecrease.ProrateImmediately;
|
||||
|
||||
const difference = new Decimal(newOptions.quantity)
|
||||
.minus(oldOptions.quantity)
|
||||
.toNumber();
|
||||
|
||||
const subItemDifference = new Decimal(newOptions.quantity)
|
||||
.minus(
|
||||
notNullish(oldOptions.upcoming_quantity)
|
||||
@@ -80,18 +69,11 @@ export const handleQuantityDowngrade = async ({
|
||||
const billingUnits =
|
||||
(cusPrice.price.config as UsagePriceConfig).billing_units || 1;
|
||||
|
||||
// const diffWithBillingUnits = new Decimal(difference)
|
||||
// .mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
|
||||
// .toNumber();
|
||||
|
||||
const newSubItemQuantity = new Decimal(subItem.quantity || 0)
|
||||
.plus(subItemDifference)
|
||||
.toNumber();
|
||||
|
||||
const stripeProration = onDecreaseToStripeProration[
|
||||
onDecrease
|
||||
] as Stripe.SubscriptionItemUpdateParams.ProrationBehavior;
|
||||
|
||||
let invoice = null;
|
||||
const createDowngradeInvoice = async () => {
|
||||
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
|
||||
|
||||
@@ -117,7 +99,7 @@ export const handleQuantityDowngrade = async ({
|
||||
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
const feature = req.features.find(
|
||||
(f: Feature) => f.internal_id == newOptions.internal_feature_id,
|
||||
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
|
||||
)!;
|
||||
const invoiceItem = constructStripeInvoiceItem({
|
||||
product,
|
||||
@@ -152,9 +134,12 @@ export const handleQuantityDowngrade = async ({
|
||||
stripeCusId: stripeSub.customer as string,
|
||||
stripeSubId: stripeSub.id,
|
||||
paymentMethod: paymentMethod || null,
|
||||
chargeAutomatically: !attachConfig.invoiceOnly,
|
||||
logger,
|
||||
});
|
||||
|
||||
invoice = finalInvoice;
|
||||
|
||||
try {
|
||||
const invoiceItems = await getInvoiceItems({
|
||||
stripeInvoice: finalInvoice,
|
||||
@@ -199,8 +184,7 @@ export const handleQuantityDowngrade = async ({
|
||||
});
|
||||
|
||||
if (cusEnt) {
|
||||
const config = cusPrice.price.config as UsagePriceConfig;
|
||||
let decrementBy = new Decimal(oldOptions.quantity)
|
||||
const decrementBy = new Decimal(oldOptions.quantity)
|
||||
.minus(new Decimal(newOptions.quantity))
|
||||
.mul(billingUnits)
|
||||
.toNumber();
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import {
|
||||
type AttachConfig,
|
||||
calculateProrationAmount,
|
||||
Feature,
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
FullCustomerPrice,
|
||||
getAmountForQuantity,
|
||||
cusProductToProduct,
|
||||
type Feature,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
type FullCustomerPrice,
|
||||
getFeatureInvoiceDescription,
|
||||
OnIncrease,
|
||||
UsagePriceConfig,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { Stripe } from "stripe";
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { Stripe } from "stripe";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
|
||||
import {
|
||||
shouldBillNow,
|
||||
shouldProrate,
|
||||
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
|
||||
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
|
||||
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
|
||||
import { cusProductToProduct } from "@autumn/shared";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
|
||||
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
|
||||
export const handleQuantityUpgrade = async ({
|
||||
@@ -34,6 +32,7 @@ export const handleQuantityUpgrade = async ({
|
||||
attachParams,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
attachConfig,
|
||||
oldOptions,
|
||||
newOptions,
|
||||
cusPrice,
|
||||
@@ -43,6 +42,7 @@ export const handleQuantityUpgrade = async ({
|
||||
req: any;
|
||||
attachParams: AttachParams;
|
||||
cusProduct: FullCusProduct;
|
||||
attachConfig: AttachConfig;
|
||||
stripeSubs: Stripe.Subscription[];
|
||||
oldOptions: FeatureOptions;
|
||||
newOptions: FeatureOptions;
|
||||
@@ -74,10 +74,7 @@ export const handleQuantityUpgrade = async ({
|
||||
const config = cusPrice.price.config as UsagePriceConfig;
|
||||
const billingUnits = config.billing_units || 1;
|
||||
|
||||
const diffWithBillingUnits = new Decimal(difference)
|
||||
.mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
|
||||
.toNumber();
|
||||
|
||||
let invoice = null;
|
||||
if (prorate && stripeSub?.status !== "trialing") {
|
||||
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
|
||||
|
||||
@@ -101,20 +98,8 @@ export const handleQuantityUpgrade = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// const amount = priceToInvoiceAmount({
|
||||
// price: cusPrice.price,
|
||||
// quantity: diffWithBillingUnits,
|
||||
// proration: prorate
|
||||
// ? {
|
||||
// start: start * 1000,
|
||||
// end: end * 1000,
|
||||
// }
|
||||
// : undefined,
|
||||
// now,
|
||||
// });
|
||||
|
||||
const feature = features.find(
|
||||
(f: Feature) => f.internal_id == newOptions.internal_feature_id,
|
||||
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
|
||||
)!;
|
||||
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
@@ -149,6 +134,7 @@ export const handleQuantityUpgrade = async ({
|
||||
stripeCusId: stripeSub.customer as string,
|
||||
stripeSubId: stripeSub.id,
|
||||
paymentMethod: paymentMethod || null,
|
||||
chargeAutomatically: !attachConfig.invoiceOnly,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -173,6 +159,7 @@ export const handleQuantityUpgrade = async ({
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invoice from stripe: ${error}`);
|
||||
}
|
||||
invoice = finalInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +171,7 @@ export const handleQuantityUpgrade = async ({
|
||||
|
||||
// Update cus ent
|
||||
|
||||
let cusEnt = getRelatedCusEnt({
|
||||
const cusEnt = getRelatedCusEnt({
|
||||
cusPrice,
|
||||
cusEnts: cusProduct.customer_entitlements,
|
||||
});
|
||||
@@ -200,4 +187,5 @@ export const handleQuantityUpgrade = async ({
|
||||
amount: incrementBy,
|
||||
});
|
||||
}
|
||||
return { invoice };
|
||||
};
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js";
|
||||
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
type AttachConfig,
|
||||
ErrCode,
|
||||
Feature,
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { Stripe } from "stripe";
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import type { Stripe } from "stripe";
|
||||
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
|
||||
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js";
|
||||
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
|
||||
|
||||
export const handleUpdateFeatureQuantity = async ({
|
||||
req,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
oldOptions,
|
||||
@@ -25,6 +23,7 @@ export const handleUpdateFeatureQuantity = async ({
|
||||
}: {
|
||||
req: any;
|
||||
attachParams: AttachParams;
|
||||
attachConfig: AttachConfig;
|
||||
cusProduct: FullCusProduct;
|
||||
stripeSubs: Stripe.Subscription[];
|
||||
oldOptions: FeatureOptions;
|
||||
@@ -62,7 +61,7 @@ export const handleUpdateFeatureQuantity = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let subItem = findStripeItemForPrice({
|
||||
const subItem = findStripeItemForPrice({
|
||||
price: price!,
|
||||
stripeItems: subToUpdate.items.data,
|
||||
}) as Stripe.SubscriptionItem;
|
||||
@@ -71,6 +70,7 @@ export const handleUpdateFeatureQuantity = async ({
|
||||
return await handleQuantityDowngrade({
|
||||
req,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSub: subToUpdate,
|
||||
oldOptions,
|
||||
@@ -81,6 +81,7 @@ export const handleUpdateFeatureQuantity = async ({
|
||||
return await handleQuantityUpgrade({
|
||||
req,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
oldOptions,
|
||||
@@ -90,34 +91,4 @@ export const handleUpdateFeatureQuantity = async ({
|
||||
subItem,
|
||||
});
|
||||
}
|
||||
|
||||
// if (!price) {
|
||||
// throw new RecaseError({
|
||||
// message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`,
|
||||
// code: ErrCode.PriceNotFound,
|
||||
// });
|
||||
// }
|
||||
|
||||
// if (!subItem) {
|
||||
// subItem = await stripeCli.subscriptionItems.create({
|
||||
// subscription: subToUpdate.id,
|
||||
// price: price.config.stripe_price_id as string,
|
||||
// quantity: newOptions.quantity,
|
||||
// proration_behavior: prorationBehavior,
|
||||
// payment_behavior: "error_if_incomplete",
|
||||
// });
|
||||
|
||||
// logger.info(
|
||||
// `updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
|
||||
// );
|
||||
// } else {
|
||||
// await stripeCli.subscriptionItems.update(subItem.id, {
|
||||
// quantity: newOptions.quantity,
|
||||
// proration_behavior: prorationBehavior,
|
||||
// payment_behavior: "error_if_incomplete",
|
||||
// });
|
||||
// logger.info(
|
||||
// `updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
|
||||
// );
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SuccessCode } from "@autumn/shared";
|
||||
import { type AttachConfig, SuccessCode } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import {
|
||||
AttachParams,
|
||||
type AttachParams,
|
||||
AttachResultSchema,
|
||||
} from "../../../cusProducts/AttachParams.js";
|
||||
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
|
||||
import { handleUpdateFeatureQuantity } from "./updateFeatureQuantity.js";
|
||||
import { AttachConfig } from "@autumn/shared";
|
||||
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
|
||||
export const handleUpdateQuantityFunction = async ({
|
||||
req,
|
||||
@@ -33,15 +33,21 @@ export const handleUpdateQuantityFunction = async ({
|
||||
subIds: cusProduct.subscription_ids || [],
|
||||
});
|
||||
|
||||
const invoices: Stripe.Invoice[] = [];
|
||||
for (const options of optionsToUpdate) {
|
||||
await handleUpdateFeatureQuantity({
|
||||
const result = await handleUpdateFeatureQuantity({
|
||||
req,
|
||||
attachParams,
|
||||
attachConfig: config,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
oldOptions: options.old,
|
||||
newOptions: options.new,
|
||||
});
|
||||
|
||||
if (result?.invoice) {
|
||||
invoices.push(result.invoice);
|
||||
}
|
||||
}
|
||||
|
||||
await CusProductService.update({
|
||||
@@ -54,6 +60,8 @@ export const handleUpdateQuantityFunction = async ({
|
||||
AttachResultSchema.parse({
|
||||
customer_id: customer.id || customer.internal_id,
|
||||
product_ids: attachParams.products.map((p) => p.id),
|
||||
invoice:
|
||||
config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined,
|
||||
code: SuccessCode.FeaturesUpdated,
|
||||
message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`,
|
||||
}),
|
||||
|
||||
@@ -229,6 +229,8 @@ export const handleUpgradeFlow = async ({
|
||||
}
|
||||
|
||||
if (res) {
|
||||
|
||||
|
||||
if (req.apiVersion.gte(ApiVersion.V1_1)) {
|
||||
res.status(200).json(
|
||||
AttachResultSchema.parse({
|
||||
|
||||
@@ -276,8 +276,11 @@ const getChangeProductBranch = async ({
|
||||
prices2: newPrices,
|
||||
});
|
||||
|
||||
// Check if it's a trial first
|
||||
|
||||
if (isUpgrade) {
|
||||
if (isMainTrialBranch({ attachParams })) {
|
||||
const isTrial = isMainTrialBranch({ attachParams });
|
||||
if (isTrial) {
|
||||
return AttachBranch.MainIsTrial;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AttachBranch,
|
||||
type AttachConfig,
|
||||
cusProductToPrices,
|
||||
cusProductToProduct,
|
||||
intervalToValue,
|
||||
ProrationBehavior,
|
||||
} from "@autumn/shared";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
attachParamsToProduct,
|
||||
attachParamToCusProducts,
|
||||
} from "./convertAttachParams.js";
|
||||
import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js";
|
||||
|
||||
export const intervalsAreSame = ({
|
||||
attachParams,
|
||||
@@ -111,6 +113,17 @@ export const getAttachConfig = async ({
|
||||
const invoiceCheckout =
|
||||
attachParams.invoiceOnly === true && !attachBody.enable_product_immediately;
|
||||
|
||||
// Check if upgrading from a default trial
|
||||
const { curMainProduct } = attachParamToCusProducts({ attachParams });
|
||||
let isUpgradingFromDefaultTrial = false;
|
||||
if (curMainProduct && branch === AttachBranch.Upgrade) {
|
||||
const product = cusProductToProduct({ cusProduct: curMainProduct });
|
||||
isUpgradingFromDefaultTrial = isDefaultTrialFullProduct({
|
||||
product,
|
||||
skipDefault: true,
|
||||
}) || false;
|
||||
}
|
||||
|
||||
const checkoutFlow =
|
||||
isPublic ||
|
||||
forceCheckout ||
|
||||
@@ -159,6 +172,7 @@ export const getAttachConfig = async ({
|
||||
? attachBody.finalize_invoice!
|
||||
: true,
|
||||
requirePaymentMethod: paymentMethodRequired,
|
||||
|
||||
};
|
||||
|
||||
return { flags, config };
|
||||
|
||||
@@ -53,6 +53,9 @@ export const getAttachFunction = async ({
|
||||
AttachBranch.MainIsTrial,
|
||||
].includes(branch);
|
||||
|
||||
// Check for upgrade/downgrade from default trial (should also use checkout)
|
||||
|
||||
|
||||
if (newScenario && onlyCheckout) {
|
||||
return AttachFunction.CreateCheckout;
|
||||
} else if (branch === AttachBranch.OneOff) {
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
AttachErrCode,
|
||||
BillingType,
|
||||
cusProductsToCusEnts,
|
||||
cusProductToPrices,
|
||||
ErrCode,
|
||||
cusProductToPrices, ErrCode,
|
||||
type FullCusProduct,
|
||||
getStartingBalance,
|
||||
type UsagePriceConfig,
|
||||
type UsagePriceConfig
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
@@ -230,14 +229,20 @@ export const handleAttachErrors = async ({
|
||||
}
|
||||
|
||||
// Invoice no payment enabled: onlyCheckout
|
||||
// Note: Upgrade from trial should proceed to checkout, so only block if NOT from trial
|
||||
|
||||
if (onlyCheckout || flags.isPublic) {
|
||||
const upgradeDowngradeFlows = [
|
||||
AttachBranch.Upgrade,
|
||||
AttachBranch.Downgrade,
|
||||
AttachBranch.MainIsTrial,
|
||||
];
|
||||
if (upgradeDowngradeFlows.includes(branch)) {
|
||||
|
||||
|
||||
|
||||
|
||||
if (
|
||||
upgradeDowngradeFlows.includes(branch)
|
||||
) {
|
||||
handleNonCheckoutErrors({
|
||||
flags,
|
||||
config,
|
||||
|
||||
@@ -61,7 +61,7 @@ export const attachParamsToPreview = async ({
|
||||
let preview: any = null;
|
||||
|
||||
if (
|
||||
branch == AttachBranch.MultiAttach ||
|
||||
branch === AttachBranch.MultiAttach ||
|
||||
notNullish(attachParams.productsList)
|
||||
) {
|
||||
preview = await getMultiAttachPreview({
|
||||
@@ -73,9 +73,9 @@ export const attachParamsToPreview = async ({
|
||||
branch,
|
||||
});
|
||||
} else if (
|
||||
func == AttachFunction.AddProduct ||
|
||||
func == AttachFunction.CreateCheckout ||
|
||||
func == AttachFunction.OneOff
|
||||
func === AttachFunction.AddProduct ||
|
||||
func === AttachFunction.CreateCheckout ||
|
||||
func === AttachFunction.OneOff
|
||||
) {
|
||||
preview = await getNewProductPreview({
|
||||
branch,
|
||||
@@ -86,7 +86,7 @@ export const attachParamsToPreview = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (func == AttachFunction.ScheduleProduct) {
|
||||
if (func === AttachFunction.ScheduleProduct) {
|
||||
preview = await getDowngradeProductPreview({
|
||||
attachParams,
|
||||
now,
|
||||
@@ -98,9 +98,9 @@ export const attachParamsToPreview = async ({
|
||||
}
|
||||
|
||||
if (
|
||||
func == AttachFunction.UpgradeDiffInterval ||
|
||||
func == AttachFunction.UpgradeSameInterval ||
|
||||
func == AttachFunction.UpdatePrepaidQuantity
|
||||
func === AttachFunction.UpgradeDiffInterval ||
|
||||
func === AttachFunction.UpgradeSameInterval ||
|
||||
func === AttachFunction.UpdatePrepaidQuantity
|
||||
) {
|
||||
preview = await getUpgradeProductPreview({
|
||||
req,
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
CusExpand,
|
||||
CusProductStatus,
|
||||
ErrCode,
|
||||
FullCusProduct,
|
||||
productToCusProduct,
|
||||
FullCusProduct, productToCusProduct
|
||||
} from "@autumn/shared";
|
||||
|
||||
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
|
||||
@@ -297,32 +296,12 @@ cusRouter.get(
|
||||
});
|
||||
|
||||
let productV2 = mapToProductV2({ product: product!, features });
|
||||
// let numVersions = await ProductService.getProductVersionCount({
|
||||
// db,
|
||||
// orgId: org.id,
|
||||
// env,
|
||||
// productId: product_id,
|
||||
// });
|
||||
|
||||
|
||||
|
||||
res.status(200).json({
|
||||
cusProduct,
|
||||
product: productV2,
|
||||
// customer,
|
||||
// product: cusProduct
|
||||
// ? {
|
||||
// ...productV2,
|
||||
// options: cusProduct.options,
|
||||
// isActive: cusProduct.status === CusProductStatus.Active,
|
||||
// isCustom: cusProduct.is_custom,
|
||||
// isCanceled:
|
||||
// cusProduct.canceled_at !== null || cusProduct.canceled,
|
||||
// cusProductId: cusProduct.id,
|
||||
// }
|
||||
// : productV2,
|
||||
// features,
|
||||
// numVersions,
|
||||
// entities: customer.entities,
|
||||
// org: createOrgResponse({ org, env }),
|
||||
});
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type Stripe from "stripe";
|
||||
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const createAndFinalizeInvoice = async ({
|
||||
stripeCli,
|
||||
@@ -9,6 +9,7 @@ export const createAndFinalizeInvoice = async ({
|
||||
invoiceItems,
|
||||
errorOnPaymentFail = true,
|
||||
voidIfFailed = true,
|
||||
chargeAutomatically = true,
|
||||
logger,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
@@ -18,12 +19,18 @@ export const createAndFinalizeInvoice = async ({
|
||||
invoiceItems?: Stripe.InvoiceItemCreateParams[];
|
||||
errorOnPaymentFail?: boolean;
|
||||
voidIfFailed?: boolean;
|
||||
chargeAutomatically?: boolean;
|
||||
logger?: any;
|
||||
}) => {
|
||||
const invoice = await stripeCli.invoices.create({
|
||||
customer: stripeCusId,
|
||||
auto_advance: false,
|
||||
subscription: stripeSubId,
|
||||
|
||||
collection_method: chargeAutomatically
|
||||
? "charge_automatically"
|
||||
: "send_invoice",
|
||||
days_until_due: chargeAutomatically ? undefined : 30,
|
||||
});
|
||||
|
||||
if (invoiceItems) {
|
||||
@@ -36,11 +43,19 @@ export const createAndFinalizeInvoice = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!chargeAutomatically) {
|
||||
let finalInvoice = await stripeCli.invoices.retrieve(invoice.id!);
|
||||
if (finalInvoice.total <= 0) {
|
||||
finalInvoice = await stripeCli.invoices.finalizeInvoice(invoice.id!);
|
||||
}
|
||||
return { invoice: finalInvoice };
|
||||
}
|
||||
|
||||
let finalInvoice = await stripeCli.invoices.finalizeInvoice(invoice.id!, {
|
||||
auto_advance: false,
|
||||
});
|
||||
|
||||
if (finalInvoice.status == "open") {
|
||||
if (finalInvoice.status === "open") {
|
||||
const {
|
||||
paid,
|
||||
error,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { AppEnv, ErrCode } from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
import { ensureStripeProductsWithEnv } from "@/external/stripe/stripeEnsureUtils.js";
|
||||
|
||||
import {
|
||||
checkKeyValid,
|
||||
createWebhookEndpoint,
|
||||
} from "@/external/stripe/stripeOnboardingUtils.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { encryptData } from "@/utils/encryptUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { OrgService } from "../OrgService.js";
|
||||
@@ -287,3 +287,22 @@ export const handleConnectStripe = async (req: any, res: any) =>
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const handleGetStripe = async (req: any, res: any) => {
|
||||
try {
|
||||
const org = await OrgService.getFromReq(req);
|
||||
|
||||
if (!isStripeConnected({ org, env: req.env })) {
|
||||
res.status(200).json({});
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeCli = createStripeCli({ org, env: req.env });
|
||||
|
||||
const account_details = await stripeCli.accounts.retrieve();
|
||||
|
||||
res.status(200).json(account_details);
|
||||
} catch (error) {
|
||||
handleRequestError({ req, error, res, action: "Get invoice" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import express, { Router } from "express";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
handleConnectStripe,
|
||||
handleGetStripe,
|
||||
} from "./handlers/handleConnectStripe.js";
|
||||
import { handleDeleteOrg } from "./handlers/handleDeleteOrg.js";
|
||||
import { handleDeleteStripe } from "./handlers/handleDeleteStripe.js";
|
||||
import { handleGetInvites } from "./handlers/handleGetInvites.js";
|
||||
import { handleGetOrg } from "./handlers/handleGetOrg.js";
|
||||
import {
|
||||
handleGetOrgMembers,
|
||||
handleRemoveMember,
|
||||
} from "./handlers/handleGetOrgMembers.js";
|
||||
|
||||
import { OrgService } from "./OrgService.js";
|
||||
import { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { createOrgResponse } from "./orgUtils.js";
|
||||
import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js";
|
||||
import { handleDeleteOrg } from "./handlers/handleDeleteOrg.js";
|
||||
import { handleGetInvites } from "./handlers/handleGetInvites.js";
|
||||
import { handleConnectStripe } from "./handlers/handleConnectStripe.js";
|
||||
import { handleDeleteStripe } from "./handlers/handleDeleteStripe.js";
|
||||
import { handleGetOrg } from "./handlers/handleGetOrg.js";
|
||||
|
||||
export const orgRouter: Router = express.Router();
|
||||
orgRouter.get("/members", handleGetOrgMembers);
|
||||
@@ -29,6 +28,8 @@ orgRouter.delete("/delete-user", async (req: any, res) => {
|
||||
|
||||
orgRouter.get("", handleGetOrg);
|
||||
|
||||
orgRouter.get("/stripe", handleGetStripe);
|
||||
|
||||
orgRouter.post("/stripe", handleConnectStripe);
|
||||
|
||||
orgRouter.delete("/stripe", handleDeleteStripe);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
type ApiPlatformUser,
|
||||
type ListPlatformUsersQuery,
|
||||
ListPlatformUsersQuerySchema,
|
||||
member,
|
||||
organizations,
|
||||
user as userTable,
|
||||
} from "@autumn/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cte } from "@/db/cteUtils/buildCte.js";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
|
||||
/**
|
||||
* Route: GET /platform/users - List users created by master org
|
||||
*/
|
||||
export const listPlatformUsers = createRoute({
|
||||
query: ListPlatformUsersQuerySchema,
|
||||
handler: async (c) => {
|
||||
const query = c.req.valid("query") as ListPlatformUsersQuery;
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
const { db, org, logger } = ctx;
|
||||
|
||||
const shouldExpandOrgs = query.expand?.includes("organizations");
|
||||
|
||||
// Build CTE for users with optional organizations
|
||||
const usersCTE = cte({
|
||||
name: "platform_users",
|
||||
from: userTable,
|
||||
where: eq(userTable.createdBy, org.id),
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
with: {
|
||||
organizations: cte({
|
||||
from: organizations,
|
||||
through: {
|
||||
table: member,
|
||||
from: eq(member.userId, userTable.id),
|
||||
to: eq(organizations.id, member.organizationId),
|
||||
},
|
||||
where: eq(organizations.created_by, org.id),
|
||||
limit: 100,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Execute the CTE
|
||||
const { data: results, count } = await usersCTE.execute({ db });
|
||||
|
||||
// Map results to API format
|
||||
const users: ApiPlatformUser[] = results.map((user) => ({
|
||||
// name: user.name,
|
||||
email: user.email,
|
||||
created_at: new Date(user.created_at).getTime(),
|
||||
...(shouldExpandOrgs &&
|
||||
user.organizations && {
|
||||
organizations: user.organizations.map((org: any) => ({
|
||||
slug: cleanOrgSlug(org.slug, org.id),
|
||||
name: org.name,
|
||||
created_at: new Date(org.createdAt).getTime(),
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
list: users,
|
||||
total: count,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove master org ID prefix from organization slug
|
||||
*/
|
||||
function cleanOrgSlug(slug: string, orgId: string): string {
|
||||
let cleanedSlug = slug;
|
||||
const prefix = `${orgId}_`;
|
||||
if (cleanedSlug.startsWith(prefix)) {
|
||||
cleanedSlug = cleanedSlug.slice(prefix.length);
|
||||
}
|
||||
// Handle the case where slug is prepended with "slug_orgId"
|
||||
const altPrefix = `_${orgId}`;
|
||||
if (cleanedSlug.endsWith(altPrefix)) {
|
||||
cleanedSlug = cleanedSlug.slice(0, -altPrefix.length);
|
||||
}
|
||||
return cleanedSlug;
|
||||
}
|
||||
11
server/src/internal/platform/honoPlatformRouter.ts
Normal file
11
server/src/internal/platform/honoPlatformRouter.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { listPlatformUsers } from "./handlers/handleListPlatformUsers.js";
|
||||
|
||||
/**
|
||||
* Hono router for platform API endpoints
|
||||
*/
|
||||
export const honoPlatformRouter = new Hono<HonoEnv>();
|
||||
|
||||
// GET /platform/users - List users created by master org
|
||||
honoPlatformRouter.get("/users", ...listPlatformUsers);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Stripe } from "stripe";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { Stripe } from "stripe";
|
||||
|
||||
export const getAllStripeSubscriptions = async ({
|
||||
numPages,
|
||||
@@ -24,6 +24,10 @@ export const getAllStripeSubscriptions = async ({
|
||||
expand: ["data.discounts.coupon"],
|
||||
});
|
||||
|
||||
if (response.data.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allSubscriptions.push(...response.data);
|
||||
|
||||
hasMore = response.has_more;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "dotenv/config";
|
||||
import fs from "node:fs";
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { ApiVersion, ApiVersionClass, type AppEnv } from "@autumn/shared";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import { subHours } from "date-fns";
|
||||
import type { Stripe } from "stripe";
|
||||
@@ -180,6 +180,7 @@ export const initScript = async ({
|
||||
features,
|
||||
logger,
|
||||
logtail: logger,
|
||||
apiVersion: new ApiVersionClass(ApiVersion.V1_2),
|
||||
} as unknown as ExtendedRequest;
|
||||
|
||||
return { stripeCli, autumnProducts, req };
|
||||
|
||||
74
shared/api/platform/platformModels.ts
Normal file
74
shared/api/platform/platformModels.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { queryStringArray } from "@api/common/queryHelpers.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Query params for GET /platform/users endpoint
|
||||
*/
|
||||
export const ListPlatformUsersQuerySchema = z.object({
|
||||
limit: z
|
||||
.number()
|
||||
.int({ error: "limit must be an integer" })
|
||||
.min(1, { error: "limit must be at least 1" })
|
||||
.max(100, { error: "limit must be at most 100" })
|
||||
.default(10),
|
||||
|
||||
offset: z
|
||||
.number({ error: "offset must be a number" })
|
||||
.int({ error: "offset must be an integer" })
|
||||
.min(0, { error: "offset must be at least 0" })
|
||||
.default(0),
|
||||
|
||||
expand: queryStringArray(z.enum(["organizations"]))
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated list of fields to expand. Currently supports: organizations",
|
||||
),
|
||||
});
|
||||
|
||||
export type ListPlatformUsersQuery = z.infer<
|
||||
typeof ListPlatformUsersQuerySchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Platform organization schema
|
||||
*/
|
||||
export const ApiPlatformOrgSchema = z.object({
|
||||
slug: z.string().describe("Organization slug without the master org prefix"),
|
||||
name: z.string().describe("Organization name"),
|
||||
created_at: z
|
||||
.number()
|
||||
.describe("Timestamp of when org was created in milliseconds since epoch"),
|
||||
});
|
||||
|
||||
export type ApiPlatformOrg = z.infer<typeof ApiPlatformOrgSchema>;
|
||||
|
||||
/**
|
||||
* Platform user schema
|
||||
*/
|
||||
export const ApiPlatformUserSchema = z.object({
|
||||
name: z.string().describe("User name"),
|
||||
email: z.string().describe("User email"),
|
||||
created_at: z
|
||||
.number()
|
||||
.describe("Timestamp of when user was created in milliseconds since epoch"),
|
||||
organizations: z
|
||||
.array(ApiPlatformOrgSchema)
|
||||
.optional()
|
||||
.describe("List of organizations created by the master org for this user"),
|
||||
});
|
||||
|
||||
export type ApiPlatformUser = z.infer<typeof ApiPlatformUserSchema>;
|
||||
|
||||
/**
|
||||
* Response schema for GET /platform/users
|
||||
*/
|
||||
export const ListPlatformUsersResponseSchema = z.object({
|
||||
list: z.array(ApiPlatformUserSchema),
|
||||
total: z.number().describe("Total number of users returned"),
|
||||
limit: z.number().describe("Limit used in the query"),
|
||||
offset: z.number().describe("Offset used in the query"),
|
||||
});
|
||||
|
||||
export type ListPlatformUsersResponse = z.infer<
|
||||
typeof ListPlatformUsersResponseSchema
|
||||
>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { invitation, member, user } from "./auth-schema.js";
|
||||
import { organizations } from "../models/orgModels/orgTable.js";
|
||||
import { invitation, member, user } from "./auth-schema.js";
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
memberships: many(member),
|
||||
|
||||
@@ -5,6 +5,7 @@ export { schemas };
|
||||
// API MODELS
|
||||
export * from "./api/models.js";
|
||||
export * from "./api/operations.js";
|
||||
export * from "./api/platform/platformModels.js";
|
||||
|
||||
// API VERSIONING SYSTEM
|
||||
export * from "./api/versionUtils/versionUtils.js";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { member } from "../../db/auth-schema.js";
|
||||
import { apiKeys } from "../devModels/apiKeyTable.js";
|
||||
import { features } from "../featureModels/featureTable.js";
|
||||
import { organizations } from "./orgTable.js";
|
||||
import { user } from "../../db/auth-schema.js";
|
||||
import { member } from "../../db/auth-schema.js";
|
||||
|
||||
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
||||
api_keys: many(apiKeys),
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"dependencies": {
|
||||
"@autumn/shared": "workspace:*",
|
||||
"@better-auth/stripe": "^1.2.12",
|
||||
"@clerk/clerk-react": "^5.24.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AdminView } from "./views/admin/AdminView";
|
||||
import { AcceptInvitation } from "./views/auth/AcceptInvitation";
|
||||
import { PasswordSignIn } from "./views/auth/components/PasswordSignIn";
|
||||
import { SignIn } from "./views/auth/SignIn";
|
||||
import CliAuth from "./views/CliAuth";
|
||||
import { Otp } from "./views/cli/Otp";
|
||||
import CustomersPage from "./views/customers/CustomersPage";
|
||||
import { AnalyticsView } from "./views/customers/customer/analytics/AnalyticsView";
|
||||
@@ -47,8 +46,6 @@ export default function App() {
|
||||
<Route path="/onboarding" element={<OnboardingView2 />} />
|
||||
<Route path="/sandbox/onboarding" element={<OnboardingView2 />} />
|
||||
|
||||
<Route path="/cli-auth" element={<CliAuth />} />
|
||||
|
||||
<Route
|
||||
path="/products"
|
||||
element={<ProductsView env={AppEnv.Live} />}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { authClient, useListOrganizations } from "@/lib/auth-client";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { FrontendOrg } from "@autumn/shared";
|
||||
import type { FrontendOrg } from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { authClient, useListOrganizations } from "@/lib/auth-client";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
export const useOrg = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
|
||||
32
vite/src/hooks/queries/useOrgStripeQuery.tsx
Normal file
32
vite/src/hooks/queries/useOrgStripeQuery.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type Stripe from "stripe";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useOrg } from "../common/useOrg";
|
||||
|
||||
/** Fetches organization Stripe account information */
|
||||
export const useOrgStripeQuery = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
// const orgId = authClient.getSession()?.session.activeOrganizationId;
|
||||
const { org } = useOrg();
|
||||
|
||||
const fetchStripeAccount = async () => {
|
||||
const { data } = await axiosInstance.get<Stripe.Account>(
|
||||
"/organization/stripe",
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<Stripe.Account | null>({
|
||||
queryKey: ["org", org?.id, "stripe"],
|
||||
queryFn: fetchStripeAccount,
|
||||
retry: false,
|
||||
enabled: !!org?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
stripeAccount: data || null,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -1,24 +1,63 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: can use any*/
|
||||
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
|
||||
export const getStripeCusLink = (customerId: string, env: AppEnv) => {
|
||||
return `https://dashboard.stripe.com${
|
||||
env == AppEnv.Live ? "" : "/test"
|
||||
}/customers/${customerId}`;
|
||||
export const getStripeCusLink = ({
|
||||
customerId,
|
||||
env,
|
||||
accountId,
|
||||
}: {
|
||||
customerId: string;
|
||||
env: AppEnv;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const baseUrl = `https://dashboard.stripe.com`;
|
||||
const accountPath = accountId ? `/${accountId}` : "";
|
||||
const withTest = env === AppEnv.Live ? "" : "/test";
|
||||
return `${baseUrl}${accountPath}${withTest}/customers/${customerId}`;
|
||||
};
|
||||
|
||||
export const getStripeSubLink = (subscriptionId: string, env: AppEnv) => {
|
||||
return `https://dashboard.stripe.com${
|
||||
env == AppEnv.Live ? "" : "/test"
|
||||
}/subscriptions/${subscriptionId}`;
|
||||
};
|
||||
export const getStripeSubScheduleLink = (scheduledId: string, env: AppEnv) => {
|
||||
return `https://dashboard.stripe.com${
|
||||
env == AppEnv.Live ? "" : "/test"
|
||||
}/subscription_schedules/${scheduledId}`;
|
||||
export const getStripeSubLink = ({
|
||||
subscriptionId,
|
||||
env,
|
||||
accountId,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
env: AppEnv;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const baseUrl = `https://dashboard.stripe.com`;
|
||||
const accountPath = accountId ? `/${accountId}` : "";
|
||||
const withTest = env === AppEnv.Live ? "" : "/test";
|
||||
return `${baseUrl}${accountPath}${withTest}/subscriptions/${subscriptionId}`;
|
||||
};
|
||||
|
||||
export const getStripeInvoiceLink = (stripeInvoice: any) => {
|
||||
return `https://dashboard.stripe.com${
|
||||
stripeInvoice.livemode ? "" : "/test"
|
||||
}/invoices/${stripeInvoice.id || stripeInvoice.stripe_id}`;
|
||||
export const getStripeSubScheduleLink = ({
|
||||
scheduledId,
|
||||
env,
|
||||
accountId,
|
||||
}: {
|
||||
scheduledId: string;
|
||||
env: AppEnv;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const baseUrl = `https://dashboard.stripe.com`;
|
||||
const accountPath = accountId ? `/${accountId}` : "";
|
||||
const withTest = env === AppEnv.Live ? "" : "/test";
|
||||
return `${baseUrl}${accountPath}${withTest}/subscription_schedules/${scheduledId}`;
|
||||
};
|
||||
|
||||
export const getStripeInvoiceLink = ({
|
||||
stripeInvoice,
|
||||
env,
|
||||
accountId,
|
||||
}: {
|
||||
stripeInvoice: any;
|
||||
env: AppEnv;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const baseUrl = `https://dashboard.stripe.com`;
|
||||
const accountPath = accountId ? `/${accountId}` : "";
|
||||
const withTest = env === AppEnv.Live ? "" : "/test";
|
||||
return `${baseUrl}${accountPath}${withTest}/invoices/${stripeInvoice.id || stripeInvoice.stripe_id}`;
|
||||
};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import axios from "axios";
|
||||
import { useSession } from "@clerk/clerk-react";
|
||||
import LoadingScreen from "./general/LoadingScreen";
|
||||
import ErrorScreen from "./general/ErrorScreen";
|
||||
export default function CliAuth() {
|
||||
let [searchParams] = useSearchParams();
|
||||
let { session } = useSession();
|
||||
|
||||
let [savedToken, setSavedToken] = useState<boolean>(false);
|
||||
let [error, setError] = useState<boolean>(false);
|
||||
|
||||
const handleCallback = async () => {
|
||||
let code = searchParams.get("code");
|
||||
let redirectUrl = searchParams.get("redirect");
|
||||
|
||||
if (!redirectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let token = await session?.getToken({
|
||||
template: "cli_template",
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.get(redirectUrl, {
|
||||
params: {
|
||||
token: token,
|
||||
},
|
||||
});
|
||||
setSavedToken(true);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, session]);
|
||||
|
||||
if (savedToken) {
|
||||
return (
|
||||
<div className="h-full w-full flex justify-center items-center">
|
||||
<div className="">✅ Successfully authenticated CLI</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorScreen>Something went wrong, please try again</ErrorScreen>;
|
||||
}
|
||||
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { useCustomerContext } from "./CustomerContext";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { Invoice, Product } from "@autumn/shared";
|
||||
import {
|
||||
type Entity,
|
||||
type Invoice,
|
||||
type InvoiceDiscount,
|
||||
Product,
|
||||
} from "@autumn/shared";
|
||||
import { toast } from "sonner";
|
||||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||
import { Row, Item } from "@/components/general/TableGrid";
|
||||
import { AdminHover } from "@/components/general/AdminHover";
|
||||
import { Item, Row } from "@/components/general/TableGrid";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||
import { useCustomerContext } from "./CustomerContext";
|
||||
import { CusProductEntityItem } from "./components/CusProductEntityItem";
|
||||
import { useCusQuery } from "./hooks/useCusQuery";
|
||||
|
||||
export const InvoicesTable = () => {
|
||||
// const { env, invoices, products, entityId, entities, showEntityView } =
|
||||
// useCustomerContext();
|
||||
const env = useEnv();
|
||||
const { entityId, showEntityView } = useCustomerContext();
|
||||
const { customer, products, entities } = useCusQuery();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const invoices = customer.invoices;
|
||||
|
||||
const entity = entities.find(
|
||||
(e: any) => e.id === entityId || e.internal_id === entityId,
|
||||
(e: Entity) => e.id === entityId || e.internal_id === entityId,
|
||||
);
|
||||
|
||||
const getStripeInvoice = async (stripeInvoiceId: string) => {
|
||||
@@ -28,16 +37,19 @@ export const InvoicesTable = () => {
|
||||
`/v1/invoices/${stripeInvoiceId}/stripe`,
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error("Failed to get invoice URL");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalDiscountAmount = (invoice: Invoice) => {
|
||||
return invoice.discounts.reduce((acc: number, discount: any) => {
|
||||
return acc + discount.amount_used;
|
||||
}, 0);
|
||||
return invoice.discounts.reduce(
|
||||
(acc: number, discount: InvoiceDiscount) => {
|
||||
return acc + discount.amount_used;
|
||||
},
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const invoicesFiltered = invoices.filter((invoice: Invoice) => {
|
||||
@@ -60,22 +72,17 @@ export const InvoicesTable = () => {
|
||||
<p className="text-t3">No invoice history found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Row
|
||||
type="header"
|
||||
className={cn(
|
||||
"grid-cols-12 pr-0",
|
||||
showEntityView && "grid-cols-15",
|
||||
)}
|
||||
>
|
||||
<Item className="col-span-3">Products</Item>
|
||||
{showEntityView && <Item className="col-span-3">Entity</Item>}
|
||||
<Item className="col-span-3">Total</Item>
|
||||
<Item className="col-span-3">Status</Item>
|
||||
<Item className="col-span-2">Created At</Item>
|
||||
<Item className="col-span-1" />
|
||||
</Row>
|
||||
</>
|
||||
<Row
|
||||
type="header"
|
||||
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
|
||||
>
|
||||
<Item className="col-span-3">Products</Item>
|
||||
{showEntityView && <Item className="col-span-3">Entity</Item>}
|
||||
<Item className="col-span-3">Total</Item>
|
||||
<Item className="col-span-3">Status</Item>
|
||||
<Item className="col-span-2">Created At</Item>
|
||||
<Item className="col-span-1" />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{invoicesFiltered.map((invoice: Invoice) => (
|
||||
@@ -85,12 +92,18 @@ export const InvoicesTable = () => {
|
||||
onClick={async () => {
|
||||
const stripeInvoice = await getStripeInvoice(invoice.stripe_id);
|
||||
if (!stripeInvoice.hosted_invoice_url) {
|
||||
const livemode = stripeInvoice.livemode;
|
||||
window.open(getStripeInvoiceLink(stripeInvoice), "_blank");
|
||||
window.open(
|
||||
getStripeInvoiceLink({
|
||||
stripeInvoice,
|
||||
env,
|
||||
accountId: stripeAccount?.id,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stripeInvoice && stripeInvoice.hosted_invoice_url) {
|
||||
if (stripeInvoice?.hosted_invoice_url) {
|
||||
window.open(stripeInvoice.hosted_invoice_url, "_blank");
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import CopyButton from "@/components/general/CopyButton";
|
||||
import { SideAccordion } from "@/components/general/SideAccordion";
|
||||
import { getStripeCusLink } from "@/utils/linkUtils";
|
||||
import { faStripe } from "@fortawesome/free-brands-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { ArrowUpRightFromSquare } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "react-router";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import Stripe from "stripe";
|
||||
import CopyButton from "@/components/general/CopyButton";
|
||||
import { SideAccordion } from "@/components/general/SideAccordion";
|
||||
import { SidebarLabel } from "@/components/general/sidebar/sidebar-label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getStripeCusLink } from "@/utils/linkUtils";
|
||||
import { useCusQuery } from "../../hooks/useCusQuery";
|
||||
|
||||
export const CustomerDetails = ({
|
||||
@@ -20,6 +22,8 @@ export const CustomerDetails = ({
|
||||
}) => {
|
||||
const { customer } = useCusQuery();
|
||||
const env = useEnv();
|
||||
const { org } = useOrg();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
|
||||
return (
|
||||
<div className="flex w-full border-b mt-[2.5px] p-4">
|
||||
@@ -100,15 +104,28 @@ export const CustomerDetails = ({
|
||||
Stripe
|
||||
</span>
|
||||
<div className="col-span-6">
|
||||
<Link
|
||||
className="!cursor-pointer hover:underline"
|
||||
to={getStripeCusLink(customer.processor?.id, env)}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="!cursor-pointer hover:underline">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="sidebarItem"
|
||||
// className="bg-white border shadow-sm rounded-md gap-2 h-6 max-h-6 !py-0"
|
||||
className="!cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
if (stripeAccount) {
|
||||
window.open(
|
||||
getStripeCusLink({
|
||||
customerId: customer.processor?.id,
|
||||
env,
|
||||
accountId: stripeAccount.id,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
} else {
|
||||
window.location.href = getStripeCusLink({
|
||||
customerId: customer.processor?.id,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faStripe}
|
||||
@@ -117,7 +134,7 @@ export const CustomerDetails = ({
|
||||
<ArrowUpRightFromSquare size={12} className="text-t2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getStripeSubLink, getStripeSubScheduleLink } from "@/utils/linkUtils";
|
||||
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
|
||||
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
|
||||
import { ArrowUpRightFromSquare } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getStripeSubLink, getStripeSubScheduleLink } from "@/utils/linkUtils";
|
||||
|
||||
export const CusProductStripeLink = ({
|
||||
cusProduct,
|
||||
@@ -11,6 +13,8 @@ export const CusProductStripeLink = ({
|
||||
cusProduct: FullCusProduct;
|
||||
}) => {
|
||||
const env = useEnv();
|
||||
const { org } = useOrg();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
return (
|
||||
<>
|
||||
{cusProduct.subscription_ids &&
|
||||
@@ -18,12 +22,24 @@ export const CusProductStripeLink = ({
|
||||
<React.Fragment>
|
||||
{cusProduct.subscription_ids.map((subId: string) => {
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
key={subId}
|
||||
to={getStripeSubLink(subId, env)}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (stripeAccount) {
|
||||
window.open(
|
||||
getStripeSubLink({
|
||||
subscriptionId: subId,
|
||||
env,
|
||||
accountId: stripeAccount.id,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
getStripeSubLink({ subscriptionId: subId, env }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
|
||||
@@ -32,7 +48,7 @@ export const CusProductStripeLink = ({
|
||||
className="text-purple-stripe"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
@@ -43,12 +59,24 @@ export const CusProductStripeLink = ({
|
||||
<React.Fragment>
|
||||
{cusProduct.scheduled_ids.map((subId: string) => {
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
key={subId}
|
||||
to={getStripeSubScheduleLink(subId, env)}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (stripeAccount) {
|
||||
window.open(
|
||||
getStripeSubScheduleLink({
|
||||
scheduledId: subId,
|
||||
env,
|
||||
accountId: stripeAccount.id,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
getStripeSubScheduleLink({ scheduledId: subId, env }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
|
||||
@@ -57,7 +85,7 @@ export const CusProductStripeLink = ({
|
||||
className="text-purple-stripe"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { type ProductItem, type ProductV2 } from "@autumn/shared";
|
||||
import type { ProductItem, ProductV2 } from "@autumn/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router";
|
||||
import { CustomToaster } from "@/components/general/CustomToaster";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { notNullish } from "@/utils/genUtils";
|
||||
import { sortProductItems } from "@/utils/productUtils";
|
||||
import ErrorScreen from "@/views/general/ErrorScreen";
|
||||
@@ -52,7 +51,6 @@ export default function CustomerProductView() {
|
||||
const { isLoading: orgLoading } = useOrg();
|
||||
const { isLoading: featuresLoading } = useFeaturesQuery();
|
||||
|
||||
const env = useEnv();
|
||||
const initialProductRef = useRef<ProductV2 | null>(null);
|
||||
|
||||
const [options, setOptions] = useState<OptionValue[]>([]);
|
||||
@@ -60,7 +58,6 @@ export default function CustomerProductView() {
|
||||
const [entityFeatureIds, setEntityFeatureIds] = useState<string[]>([]);
|
||||
|
||||
const version = searchParams.get("version");
|
||||
const customer_product_id = searchParams.get("id");
|
||||
|
||||
const {
|
||||
product: originalProduct,
|
||||
@@ -94,12 +91,17 @@ export default function CustomerProductView() {
|
||||
|
||||
const product = originalProduct;
|
||||
|
||||
setProduct(product);
|
||||
console.log('[CPV] effect', { prodId: originalProduct.id, v: originalProduct.version, cusId: cusProduct?.id });
|
||||
|
||||
// Update initialProductRef BEFORE setProduct to ensure useAttachState
|
||||
// effect has the correct baseline when it runs
|
||||
initialProductRef.current = structuredClone({
|
||||
...product,
|
||||
items: sortProductItems(product.items),
|
||||
});
|
||||
|
||||
setProduct(product);
|
||||
|
||||
setEntityFeatureIds(
|
||||
Array.from(
|
||||
new Set(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CusService } from "@/services/customers/CusService";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
@@ -45,6 +46,7 @@ export const AttachModal = ({
|
||||
}) => {
|
||||
const { customer, entities } = useCusQuery();
|
||||
const { product, entityId, attachState, version } = useProductContext();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
|
||||
const navigation = useNavigate();
|
||||
const env = useEnv();
|
||||
@@ -169,7 +171,14 @@ export const AttachModal = ({
|
||||
if (data.checkout_url) {
|
||||
window.open(data.checkout_url, "_blank");
|
||||
} else if (data.invoice) {
|
||||
window.open(getStripeInvoiceLink(data.invoice), "_blank");
|
||||
window.open(
|
||||
getStripeInvoiceLink({
|
||||
stripeInvoice: data.invoice,
|
||||
env,
|
||||
accountId: stripeAccount?.id,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
navigateTo(`/customers/${cusId}`, navigation, env);
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ export const useAttachState = ({
|
||||
free_trial: initialProductRef.current?.free_trial || null,
|
||||
});
|
||||
|
||||
console.log('[UAS] effect', { changed: hasItemsChanged, refLen: initialProductRef.current?.items?.length, hasPrepaid: productHasPrepaid(product.items) });
|
||||
|
||||
setItemsChanged(hasItemsChanged);
|
||||
}, [product]);
|
||||
|
||||
@@ -163,21 +165,26 @@ export const useAttachState = ({
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (cusProduct && !itemsChanged) {
|
||||
if (flags.isOneOff) {
|
||||
return "Attach Product";
|
||||
const result = (() => {
|
||||
if (cusProduct && !itemsChanged) {
|
||||
if (flags.isOneOff) {
|
||||
return "Attach Product";
|
||||
}
|
||||
|
||||
if (flags.hasPrepaid) {
|
||||
return "Update prepaid quantity";
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.hasPrepaid) {
|
||||
return "Update prepaid quantity";
|
||||
if (flags.isCanceled) {
|
||||
return "Renew Product";
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isCanceled) {
|
||||
return "Renew Product";
|
||||
}
|
||||
return "Attach Product";
|
||||
})();
|
||||
|
||||
return "Attach Product";
|
||||
console.log('[BTN]', { text: result, cusId: cusProduct?.id, changed: itemsChanged, prepaid: flags.hasPrepaid });
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cusProductToProduct,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
mapToProductV2,
|
||||
notNullish,
|
||||
type ProductV2,
|
||||
productToCusProduct,
|
||||
ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const useCusProductCache = ({
|
||||
customerId,
|
||||
@@ -81,15 +81,12 @@ export const useCusProductCache = ({
|
||||
: cachedData.customer.entity?.internal_id;
|
||||
|
||||
const cusProduct = productToCusProduct({
|
||||
productId: productId!,
|
||||
productId,
|
||||
cusProducts,
|
||||
internalEntityId,
|
||||
version: queryStates.version,
|
||||
cusProductId: queryStates.customerProductId,
|
||||
inStatuses: ACTIVE_STATUSES,
|
||||
// version: undefined,
|
||||
// cusProductId: undefined,
|
||||
// inStatuses: ACTIVE_STATUSES,
|
||||
});
|
||||
|
||||
if (cusProduct) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { FullCusProduct, ProductV2 } from "@autumn/shared";
|
||||
import type { FullCusProduct, ProductV2 } from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useParams } from "react-router";
|
||||
import { useCusProductCache } from "./useCusProductCache";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useCusProductCache } from "./useCusProductCache";
|
||||
|
||||
export const useCusProductQuery = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
@@ -28,7 +28,7 @@ export const useCusProductQuery = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const cachedCusProduct = useMemo(getCachedCusProduct, [getCachedCusProduct]);
|
||||
const cachedCusProduct = useMemo(getCachedCusProduct, []);
|
||||
|
||||
const fetcher = async () => {
|
||||
const queryParams = {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { ProductV2 } from "@autumn/shared";
|
||||
import type { CheckoutResult } from "autumn-js";
|
||||
import { ArrowUpRightFromSquare, Loader2, Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
CustomDialogBody,
|
||||
CustomDialogContent,
|
||||
CustomDialogFooter,
|
||||
} from "@/components/general/modal-components/DialogContentWrapper";
|
||||
import FieldLabel from "@/components/general/modal-components/FieldLabel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InvoiceCustomerButton } from "../components/InvoiceCustomerButton";
|
||||
import FieldLabel from "@/components/general/modal-components/FieldLabel";
|
||||
import { ArrowUpRightFromSquare, Loader2, Plus, X } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -16,22 +19,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ProductV2 } from "@autumn/shared";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useAxiosSWR } from "@/services/useAxiosSwr";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { MultiAtttachLines } from "./MultiAttachLines";
|
||||
import { CheckoutResult } from "autumn-js";
|
||||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||
import { formatAmount } from "@/utils/product/productItemUtils";
|
||||
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { AddRewardButton, MultiAttachRewards } from "./MultiAttachRewards";
|
||||
import { useAxiosSWR } from "@/services/useAxiosSwr";
|
||||
import { useCusQuery } from "../../hooks/useCusQuery";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { InvoiceCustomerButton } from "../components/InvoiceCustomerButton";
|
||||
import { getCusProductMinQuantity } from "../utils/getCusProductMinQuantity";
|
||||
import { MultiAtttachLines } from "./MultiAttachLines";
|
||||
import { AddRewardButton, MultiAttachRewards } from "./MultiAttachRewards";
|
||||
|
||||
export const MultiAttachDialog = ({
|
||||
open,
|
||||
@@ -43,6 +44,8 @@ export const MultiAttachDialog = ({
|
||||
// const { customer, cusMutate, products, org } = useCustomerContext();
|
||||
const { org } = useOrg();
|
||||
const { customer, products, refetch } = useCusQuery();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
const env = useEnv();
|
||||
|
||||
const axiosInstance = useAxiosInstance();
|
||||
|
||||
@@ -157,7 +160,14 @@ export const MultiAttachDialog = ({
|
||||
});
|
||||
|
||||
if (data.invoice) {
|
||||
window.open(getStripeInvoiceLink(data.invoice), "_blank");
|
||||
window.open(
|
||||
getStripeInvoiceLink({
|
||||
stripeInvoice: data.invoice,
|
||||
env,
|
||||
accountId: stripeAccount?.id,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
|
||||
await refetch();
|
||||
|
||||
@@ -28,10 +28,19 @@ export default defineConfig({
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Exclude workspace dependencies from pre-bundling to avoid cache issues
|
||||
exclude: ["@autumn/shared"],
|
||||
exclude: [
|
||||
"@autumn/shared",
|
||||
"better-auth",
|
||||
"better-auth/react",
|
||||
"@better-auth/stripe",
|
||||
],
|
||||
// Force re-optimization on server start to catch workspace changes
|
||||
force: process.env.FORCE_OPTIMIZE === "true",
|
||||
// Include specific dependencies that might cause issues
|
||||
include: [],
|
||||
},
|
||||
// Clear cache on config change
|
||||
cacheDir: "node_modules/.vite",
|
||||
server: {
|
||||
host: "0.0.0.0", // Required for Docker
|
||||
port: process.env.VITE_PORT
|
||||
|
||||
Reference in New Issue
Block a user