Merge remote-tracking branch 'origin/auth-better' into fix/github-ready

This commit is contained in:
John Yeo
2025-06-16 19:51:18 +01:00
224 changed files with 17027 additions and 15031 deletions

55
.dockerignore Normal file
View File

@@ -0,0 +1,55 @@
# Dependencies
node_modules/
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
**/dist/
**/build/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
logs/
*.log
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Tests
**/tests/
**/*.test.*
**/*.spec.*

4
.gitignore vendored
View File

@@ -62,6 +62,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# example env files
!.env.example
@@ -75,4 +76,5 @@ next-env.d.ts
*.rdb
packages/useautumn
shared/drizzle

19
commands.sh Normal file
View File

@@ -0,0 +1,19 @@
RESTART EVERYTHING
docker system prune -a --volumes
# DB
docker compose -f docker-compose.db.yml up --build
# Create DB tables
pnpm
# docker volume rm main-repo_shared-node-modules main-repo_root-node-modules main-repo_vite-node-modules
# Dev
docker compose -f docker-compose.dev.yml down
docker volume rm autumn-oss_shared-node-modules autumn-oss_root-node-modules autumn-oss_vite-node-modules
docker compose -f docker-compose.dev.yml build --no-cache
docker compose -f docker-compose.dev.yml up --build
# Prod
docker compose -f docker-compose.prod.yml up --build
---

97
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,97 @@
services:
valkey:
image: docker.io/bitnami/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
volumes:
- valkey-data:/bitnami/valkey/data
healthcheck:
test: ['CMD', 'redis-cli', '-h', 'localhost', '-p', '6379', 'ping']
interval: 10s
timeout: 5s
retries: 5
# ports:
# - "6379:6379"
restart: unless-stopped
shared:
build:
dockerfile: docker/dev.dockerfile
context: .
target: shared
volumes:
- ./shared:/app/shared
- shared-dist:/app/shared/dist
- shared-node-modules:/app/shared/node_modules
- root-node-modules:/app/node_modules
environment:
- NODE_ENV=development
restart: unless-stopped
# Vite frontend
vite:
build:
dockerfile: docker/dev.dockerfile
context: .
target: vite
ports:
- "3000:3000"
volumes:
- ./vite:/app/vite
- shared-dist:/app/shared/dist
- vite-node-modules:/app/vite/node_modules
- root-node-modules:/app/node_modules
environment:
- NODE_ENV=development
depends_on:
- shared
restart: unless-stopped
# Main Express server
server:
build:
dockerfile: docker/dev.dockerfile
context: .
target: server
ports:
- "8080:8080"
volumes:
- ./server:/app/server
- shared-dist:/app/shared/dist
- root-node-modules:/app/node_modules
environment:
- NODE_ENV=development
- REDIS_URL=redis://valkey:6379
depends_on:
- shared
restart: unless-stopped
# BullMQ Workers
workers:
build:
dockerfile: docker/dev.dockerfile
context: .
target: workers
volumes:
# Mount server source for hot reload (workers use server code)
- ./server:/app/server
- shared-dist:/app/shared/dist
- root-node-modules:/app/node_modules
environment:
- NODE_ENV=development
- REDIS_URL=redis://valkey:6379
depends_on:
- shared
restart: unless-stopped
volumes:
# Shared package dist output
shared-dist:
valkey-data:
# Node modules volumes to avoid host/container conflicts
shared-node-modules:
server-node-modules:
vite-node-modules:
root-node-modules:

46
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,46 @@
version: "3.8"
services:
valkey:
image: docker.io/bitnami/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
volumes:
- valkey-data:/bitnami/valkey/data
healthcheck:
test: ['CMD', 'redis-cli', '-h', 'localhost', '-p', '6379', 'ping']
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"
restart: unless-stopped
vite:
build:
context: .
dockerfile: docker/prod.dockerfile
target: vite-prod
ports:
- "3000:3000"
restart: always
server:
environment:
- REDIS_URL=redis://valkey:6379
build:
context: .
dockerfile: docker/prod.dockerfile
target: server-prod
ports:
- "8080:8080"
restart: always
workers:
environment:
- REDIS_URL=redis://valkey:6379
build:
context: .
dockerfile: docker/prod.dockerfile
target: workers-prod
restart: always

43
docker/dev.dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# Multi-stage Dockerfile for Autumn development
FROM node:18-alpine AS base
WORKDIR /app
RUN npm install -g pnpm
COPY package*.json ./
COPY pnpm-workspace.yaml ./
COPY pnpm-lock.yaml ./
COPY shared/package*.json ./shared/
COPY server/package*.json ./server/
COPY vite/package*.json ./vite/
RUN pnpm install
RUN npm install -g nodemon tsx
# Stage 2: /shared
FROM base AS shared
COPY shared/ ./shared/
WORKDIR /app/shared
RUN pnpm run build
CMD ["pnpm", "run", "dev"]
# Stage 3: /vite
FROM base AS vite
WORKDIR /app/vite
COPY vite/ ./
EXPOSE 3000
CMD ["pnpm", "run", "dev"]
# Stage 4: /server
FROM base AS server
WORKDIR /app/server
COPY server/ ./
EXPOSE 8080
CMD ["pnpm", "run", "dev"]
# Stage 5: Workers
FROM base AS workers
WORKDIR /app/server
COPY server/ ./
CMD ["pnpm", "run", "workers"]

View File

@@ -0,0 +1,42 @@
version: '3.8'
services:
# Redis for caching and BullMQ
valkey:
image: docker.io/bitnami/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
volumes:
- valkey-data:/bitnami/valkey/data
healthcheck:
test: ['CMD', 'redis-cli', '-h', 'localhost', '-p', '6379', 'ping']
interval: 10s
timeout: 5s
retries: 5
# PostgreSQL database
postgres:
image: postgres:15-alpine
container_name: autumn-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: autumn
POSTGRES_USER: postgres
POSTGRES_PASSWORD: autumn_dev_password
volumes:
- postgres-data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
valkey-data:
postgres-data:
networks:
autumn-network:

60
docker/prod.dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# ---- Base dependencies ----
FROM node:18-alpine AS base
WORKDIR /app
RUN npm install -g pnpm serve typescript tsc tsc-alias tsx
COPY package*.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY shared/package*.json ./shared/
COPY server/package*.json ./server/
COPY vite/package*.json ./vite/
RUN pnpm install
# # ---- Build shared ----
# FROM base AS shared-build
# COPY shared/ ./shared/
# RUN pnpm -F shared build
# # ---- Build frontend (vite) ----
FROM base AS vite-build
COPY . .
WORKDIR /app
RUN pnpm run vite:build
# ---- Build backend (server) ----
FROM base AS server-build
COPY . .
WORKDIR /app
RUN pnpm run server:build
# ---- Production frontend image ----
FROM vite-build AS vite-prod
EXPOSE 3000
WORKDIR /app
CMD ["pnpm", "run", "vite:start"]
FROM server-build AS server-prod
EXPOSE 8080
WORKDIR /app
CMD ["pnpm", "run", "server:start"]
FROM server-build AS workers-prod
EXPOSE 8080
WORKDIR /app
CMD ["pnpm", "run", "server:workers"]
# # ---- Production backend image ----
# FROM node:18-alpine AS server-prod
# COPY --from=server-build /app/server/dist ./dist
# COPY --from=server-build /app/server/package.json ./
# COPY --from=server-build /app/server/node_modules ./node_modules
# EXPOSE 8080
# CMD ["pnpm", "run", "server:start"]
# # ---- Production workers image ----
# FROM node:18-alpine AS workers-prod
# COPY --from=server-build /app/server/dist ./dist
# COPY --from=server-build /app/server/package.json ./
# COPY --from=server-build /app/server/node_modules ./node_modules
# CMD ["pnpm", "run", "server:workers"]

View File

@@ -1,5 +0,0 @@
https://docs.fontawesome.com/web/use-with/react
https://docs.fontawesome.com/web/setup/packages#1-configure-access
In the frontend (vite) run `npm link @useautumn/react`

View File

@@ -2,25 +2,32 @@
"name": "autumn",
"private": true,
"workspaces": [
"frontend",
"server",
"shared",
"tests",
"vite"
],
"type": "module",
"scripts": {
"frontend:build": "npm run build -w shared && npm run build -w frontend",
"frontend:start": "npm run start -w frontend",
"vite:build": "npm run build -w shared && npm run build -w vite",
"vite:start": "npm run start -w vite",
"server:build": "npm run build -w shared && npm run prod:build -w server",
"server:start": "npm run prod:start -w server",
"vite:build": "pnpm -F shared build && pnpm -F vite build",
"vite:start": "pnpm -F vite start",
"server:build": "pnpm -F shared build && pnpm -F server prod:build",
"server:start": "pnpm -F server prod:start",
"server:cron": "npm run cron:start -w server",
"server:workers": "npm run workers:start -w server",
"dev": "concurrently \"cd server && npm run dev\" \"cd vite && npm run dev\" \"redis-server\""
"dev": "concurrently \"cd server && npm run dev\" \"cd vite && npm run dev\" \"redis-server\"",
"setup": "node setup.js",
"db:push": "pnpm -F shared db:push",
"db:generate": "pnpm -F shared db:generate",
"db:migrate": "pnpm -F shared db:migrate"
},
"dependencies": {
"chalk": "^5.3.0",
"drizzle-kit": "^0.31.1"
},
"devDependencies": {
"@types/node": "^24.0.3",
"dotenv": "^16.5.0",
"inquirer": "^12.6.3",
"concurrently": "^9.1.2"
}
}

10850
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- 'shared'
- 'server'
- 'vite'

3
run.sh Executable file
View File

@@ -0,0 +1,3 @@
if [[ $1 == *"docker-compose"* ]]; then
docker compose -f "$1" up
fi

15
server/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# AUTH
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:8080
CLIENT_URL=http://localhost:3000
# STRIPE REQUIRED
ENCRYPTION_IV=
ENCRYPTION_PASSWORD=
SERVER_URL=
# DATABASE
DATABASE_URL=
# IF using external redis
# REDIS_URL=redis://valkey:6379

112
server/auth-schema.ts Normal file
View File

@@ -0,0 +1,112 @@
import {
pgTable,
text,
timestamp,
boolean,
integer,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified")
.$defaultFn(() => false)
.notNull(),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true })
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
}).enableRLS();
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
}).enableRLS();
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", {
withTimezone: true,
}),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
}).enableRLS();
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).$defaultFn(
() => /* @__PURE__ */ new Date(),
),
updatedAt: timestamp("updated_at", { withTimezone: true }).$defaultFn(
() => /* @__PURE__ */ new Date(),
),
}).enableRLS();
export const organization = pgTable("organization", {
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").unique(),
logo: text("logo"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
metadata: text("metadata"),
}).enableRLS();
export const member = pgTable("member", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
}).enableRLS();
export const invitation = pgTable("invitation", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
}).enableRLS();

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import {
Html,
Head,
Body,
Container,
Section,
Text,
Heading,
Tailwind,
} from "@react-email/components";
const OTPEmail = (props: { otpCode: string }) => {
return (
<Html lang="en" dir="ltr">
<Tailwind>
<Head />
<Body className="bg-white font-sans">
<Container className="bg-white max-w-[600px] mx-auto px-[40px] py-[40px]">
<Heading className="text-gray-900 text-[24px] font-bold mb-[24px]">
Verification code
</Heading>
<Text className="text-gray-800 text-[16px] leading-[24px] mb-[24px]">
Enter the following verification code when prompted:
</Text>
<Text className="text-[32px] font-bold text-gray-900 font-mono mb-[24px]">
{props.otpCode}
</Text>
<Text className="text-gray-800 text-[16px] leading-[24px] mb-[40px]">
To protect your account, do not share this code.
</Text>
{/* Footer */}
<Section className="border-t border-gray-200 pt-[24px]">
<Text className="text-gray-500 text-[12px] leading-[16px] m-0">
Autumn
<br />
2261 Market Street STE 22390
<br />
San Francisco, CA, US, 94114
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
OTPEmail.PreviewProps = {
otpCode: "884085",
};
export default OTPEmail;

View File

@@ -5,6 +5,7 @@
"main": "index.js",
"type": "module",
"scripts": {
"email": "email dev -p 3001",
"dev": "NODE_ENV=development nodemon --no-deprecation --exec tsx src/index.ts --ignore scripts --ignore tests",
"start": "tsx src/index.ts",
"workers": "tsx watch src/workers.ts",
@@ -36,18 +37,20 @@
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@anthropic-ai/sdk": "^0.32.1",
"@autumn/shared": "*",
"@autumn/shared": "workspace:*",
"@clerk/express": "^1.3.22",
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"@logtail/node": "^0.5.2",
"@react-email/components": "^0.0.42",
"@supabase/supabase-js": "^2.46.2",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"ai": "^4.3.10",
"autumn-js": "^0.0.64",
"better-auth": "^1.2.9",
"body-parser": "^1.20.3",
"bullmq": "^5.31.1",
"bullmq": "^5.54.0",
"chai": "^5.1.2",
"chai-http": "^5.1.1",
"chalk": "^5.3.0",
@@ -58,6 +61,7 @@
"currency-symbol-map": "^5.1.0",
"date-fns": "^4.1.0",
"decimal.js": "^10.5.0",
"detect-content-type": "^1.2.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.43.1",
"express": "^4.21.1",
@@ -66,6 +70,7 @@
"ioredis": "^5.5.0",
"ksuid": "^3.0.0",
"lodash-es": "^4.17.21",
"mime-detect": "^1.3.0",
"nodemon": "^3.1.7",
"openai": "^4.85.2",
"pg": "^8.13.1",
@@ -73,12 +78,14 @@
"pino-pretty": "^13.0.0",
"postgres": "^3.4.7",
"posthog-node": "^4.17.2",
"react": "^18.2.0",
"recaseai": "^0.0.37",
"resend": "^4.1.1",
"stripe": "^17.5.0",
"svix": "^1.45.1",
"tsc-alias": "^1.8.16",
"ws": "^8.18.0"
"ws": "^8.18.0",
"zod": "^3.25.23"
},
"devDependencies": {
"@types/chai": "^5.0.1",
@@ -87,9 +94,13 @@
"@types/mocha": "^10.0.10",
"@types/node": "^22.13.4",
"@types/pg": "^8.11.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.1",
"mocha": "^11.1.0",
"puppeteer": "^24.2.0",
"react-email": "4.0.16",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.4",

View File

@@ -5,6 +5,8 @@ filename=$1
# Check if the file path contains "shell"
if [[ $filename == *"shell"* ]]; then
$filename
elif [[ $filename == *"/tests/"* ]]; then
@@ -17,6 +19,9 @@ elif [[ $filename == *"/tests/"* ]]; then
else
./test.sh custom $path_after_tests
fi
elif [[ $filename == *".sh"* ]]; then
$filename
else
npx tsx $filename
fi

View File

@@ -3,7 +3,10 @@ dotenv.config();
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { schemas } from "@autumn/shared";
import { schemas as schema } from "@autumn/shared";
export let client = postgres(process.env.DATABASE_URL!);
export let db = drizzle(client, { schema });
export const initDrizzle = (params?: { maxConnections?: number }) => {
let maxConnections = params?.maxConnections || 10;
@@ -11,10 +14,7 @@ export const initDrizzle = (params?: { maxConnections?: number }) => {
max: maxConnections,
});
const db = drizzle(client, {
schema: schemas,
// logger: true, // Enable SQL logging for debugging
});
const db = drizzle(client, { schema });
return { db, client };
};

View File

@@ -1,7 +1,7 @@
import express from "express";
import express, { Router } from "express";
import { Webhook } from "svix";
export const autumnWebhookRouter = express.Router();
export const autumnWebhookRouter: Router = express.Router();
const verifyAutumnWebhook = async (req: any, res: any) => {
const wh = new Webhook(process.env.AUTUMN_WEBHOOK_SECRET!);
@@ -54,5 +54,5 @@ autumnWebhookRouter.post(
success: true,
message: "Webhook received",
});
}
},
);

View File

@@ -15,10 +15,10 @@ export class CacheManager {
console.log("Initializing Cache Manager...");
if (this.initialized) return;
const redisUrl = process.env.REDIS_BACKUP_URL;
const redisUrl = process.env.REDIS_BACKUP_URL || process.env.REDIS_URL;
if (!redisUrl) {
throw new Error("Redis URL not configured");
throw new Error("Cache error: no redis connection string set in env");
}
this.client = new Redis(redisUrl, {

View File

@@ -1,3 +1,6 @@
import dotenv from "dotenv";
dotenv.config();
import { initLogger } from "@/errors/logger.js";
import { Logtail } from "@logtail/node";
@@ -36,6 +39,10 @@ const createLogMethod = (pinoMethod: any, logtailMethod: any) => {
pinoMethod(message);
}
if (!logtailMethod) {
return;
}
// Logtail format: message first, then object (if exists)
if (Object.keys(mergedObj).length > 0) {
logtailMethod(message, mergedObj);
@@ -45,34 +52,42 @@ const createLogMethod = (pinoMethod: any, logtailMethod: any) => {
};
};
export const createLogtail = () => {
const logtail = new Logtail(process.env.LOGTAIL_SOURCE_TOKEN!, {
endpoint: process.env.LOGTAIL_INGESTING_HOST!,
});
export const createLogger = ({
sourceToken,
ingestingHost,
}: {
sourceToken: string;
ingestingHost: string;
}) => {
let logtail: any;
if (sourceToken && ingestingHost) {
logtail = new Logtail(sourceToken, {
endpoint: ingestingHost,
});
}
// Create a custom logger that logs to both Logtail and console
const logger = {
debug: createLogMethod(
pinoLogger.debug.bind(pinoLogger),
logtail.debug.bind(logtail),
logtail?.debug.bind(logtail),
),
info: createLogMethod(
pinoLogger.info.bind(pinoLogger),
logtail.info.bind(logtail),
logtail?.info.bind(logtail),
),
warn: createLogMethod(
pinoLogger.warn.bind(pinoLogger),
logtail.warn.bind(logtail),
logtail?.warn.bind(logtail),
),
error: createLogMethod(
pinoLogger.error.bind(pinoLogger),
logtail.error.bind(logtail),
logtail?.error.bind(logtail),
),
use: (fn: any) => {
logtail.use(fn);
logtail?.use(fn);
},
getLogtail: () => logtail,
flush: () => logtail.flush(),
flush: () => logtail?.flush(),
};
return logger;
@@ -90,7 +105,21 @@ export const createLogtailWithContext = (context: any) => {
return logtail;
};
export const createLogtail = () => {
return createLogger({
sourceToken: process.env.LOGTAIL_SOURCE_TOKEN!,
ingestingHost: process.env.LOGTAIL_INGESTING_HOST!,
});
};
export const createLogtailAll = () => {
if (
!process.env.LOGTAIL_ALL_SOURCE_TOKEN ||
!process.env.LOGTAIL_ALL_INGESTING_HOST
) {
return null;
}
const logtail = new Logtail(process.env.LOGTAIL_ALL_SOURCE_TOKEN!, {
endpoint: process.env.LOGTAIL_ALL_INGESTING_HOST!,
});
@@ -98,5 +127,5 @@ export const createLogtailAll = () => {
return logtail;
};
// const logtail = createLogtailLogger();
// export default logtail;
export const logger = createLogtail();
export const logtailAll = createLogtailAll();

View File

@@ -2,11 +2,9 @@ import dotenv from "dotenv";
dotenv.config();
import { PostHog } from "posthog-node";
import { initLogger } from "@/errors/logger.js";
import { logger } from "../logtail/logtailUtils.js";
export const createPosthogCli = () => {
const logger = initLogger();
if (!process.env.POSTHOG_API_KEY) {
logger.warn("POSTHOG_API_KEY not set, skipping posthog");
return null;

View File

@@ -1,21 +1,23 @@
import { Resend } from "resend";
export const createCli = () => {
export const createResendCli = () => {
return new Resend(process.env.RESEND_API_KEY);
};
export const sendTextEmail = async ({
from,
to,
subject,
body,
}: {
from?: string;
to: string;
subject: string;
body: string;
}) => {
const resend = createCli();
const resend = createResendCli();
await resend.emails.send({
from: `Ayush <ayush@${process.env.RESEND_DOMAIN}>`,
from: from || `Ayush <ayush@${process.env.RESEND_DOMAIN}>`,
to: to,
subject: subject,
text: body,
@@ -23,17 +25,19 @@ export const sendTextEmail = async ({
};
export const sendHtmlEmail = async ({
from,
to,
subject,
body,
}: {
from?: string;
to: string;
subject: string;
body: string;
}) => {
const resend = createCli();
const resend = createResendCli();
await resend.emails.send({
from: `Ayush <ayush@${process.env.RESEND_DOMAIN}>`,
from: from || `Ayush <ayush@${process.env.RESEND_DOMAIN}>`,
to: to,
subject: subject,
html: body,

View File

@@ -0,0 +1,23 @@
import { logger } from "../logtail/logtailUtils.js";
export function safeResend<T extends (...args: any[]) => any>({
fn,
action,
}: {
fn: T;
action: string;
}): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
return async (...args: Parameters<T>) => {
if (!process.env.RESEND_API_KEY || !process.env.RESEND_DOMAIN) {
logger.warn(
`RESEND_API_KEY or RESEND_DOMAIN is not set, skipping ${action}`,
);
return;
}
try {
return await fn(...args);
} catch (error) {
logger.error(`Error ${action}: ${error}`);
}
};
}

View File

@@ -89,7 +89,7 @@ export const createStripeMeteredPrice = async ({
const stripePrice = await stripeCli.prices.create({
...productData,
...priceAmountData,
currency: org.default_currency,
currency: org.default_currency || "usd",
nickname: `Autumn Price (${feature!.name}) [Placeholder]`,
recurring: {
...(billingIntervalToStripe(price.config!.interval!) as any),
@@ -194,7 +194,7 @@ export const createStripeArrearProrated = async ({
let stripePrice = await stripeCli.prices.create({
...productData,
currency: org.default_currency,
currency: org.default_currency || "usd",
...priceAmountData,
recurring: {
...(recurringData as any),

View File

@@ -26,7 +26,7 @@ export const createStripeFixedPrice = async ({
const stripePrice = await stripeCli.prices.create({
product: product.processor!.id,
unit_amount: amount,
currency: org.default_currency,
currency: org.default_currency!,
recurring: {
...(billingIntervalToStripe(config.interval!) as any),
},

View File

@@ -255,7 +255,7 @@ export const createStripeInArrearPrice = async ({
const stripePrice = await stripeCli.prices.create({
...productData,
...priceAmountData,
currency: org.default_currency,
currency: org.default_currency!,
recurring: {
...(billingIntervalToStripe(price.config!.interval!) as any),
meter: meter!.id,

View File

@@ -110,7 +110,7 @@ export const createStripePrepaid = async ({
...productData,
// unit_amount_decimal: (amount * 100).toString(),
unit_amount_decimal: unitAmountDecimalStr,
currency: org.default_currency,
currency: org.default_currency!,
});
config.stripe_product_id = stripePrice.product as string;
@@ -133,7 +133,7 @@ export const createStripePrepaid = async ({
stripePrice = await stripeCli.prices.create({
...productData,
currency: org.default_currency,
currency: org.default_currency!,
...priceAmountData,
recurring: {
...(recurringData as any),

View File

@@ -1,4 +1,5 @@
import { AppEnv } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import { AppEnv, ErrCode } from "@autumn/shared";
import Stripe from "stripe";
export const checkKeyValid = async (apiKey: string) => {
@@ -18,8 +19,19 @@ export const createWebhookEndpoint = async (
) => {
const stripe = new Stripe(apiKey);
const webhookBaseUrl =
process.env.SERVER_URL || process.env.STRIPE_WEBHOOK_URL;
if (!webhookBaseUrl) {
throw new RecaseError({
message: "Stripe webhook baseURL not found",
code: ErrCode.StripeKeyInvalid,
statusCode: 500,
});
}
const endpoint = await stripe.webhookEndpoints.create({
url: `${process.env.SERVER_URL}/webhooks/stripe/${orgId}/${env}`,
url: `${webhookBaseUrl}/webhooks/stripe/${orgId}/${env}`,
enabled_events: [
"customer.subscription.created",
"customer.subscription.updated",

View File

@@ -1,7 +1,10 @@
import express, { Router } from "express";
import stripe, { Stripe } from "stripe";
import chalk from "chalk";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { AuthType, LoggerAction, Organization } from "@autumn/shared";
import express from "express";
import stripe, { Stripe } from "stripe";
import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js";
@@ -10,7 +13,6 @@ import { getStripeWebhookSecret } from "@/internal/orgs/orgUtils.js";
import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
import chalk from "chalk";
import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js";
import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js";
import { createLogtailWithContext } from "../logtail/logtailUtils.js";
@@ -18,7 +20,7 @@ import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDel
import { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "./utils.js";
export const stripeWebhookRouter = express.Router();
export const stripeWebhookRouter: Router = express.Router();
const logStripeWebhook = ({
req,

23
server/src/external/supabase/safeSb.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
import { logger } from "../logtail/logtailUtils.js";
export function safeSb<T extends (...args: any[]) => any>({
fn,
action,
}: {
fn: T;
action: string;
}): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
return async (...args: Parameters<T>) => {
if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_KEY) {
logger.warn(
`SUPABASE_URL or SUPABASE_SERVICE_KEY is not set, skipping ${action}`,
);
return;
}
try {
return await fn(...args);
} catch (error) {
logger.error(`Error ${action}: ${error}`);
}
};
}

View File

@@ -0,0 +1,42 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { createSupabaseClient } from "../supabaseUtils.js";
export const uploadFile = async ({
path,
file,
contentType,
}: {
path: string;
file: Buffer;
contentType?: string;
}) => {
const sb = createSupabaseClient();
const { data, error } = await sb.storage.from("autumn").upload(path, file, {
upsert: true,
contentType,
});
if (error) {
throw error;
}
return data;
};
export const getUploadUrl = async ({ path }: { path: string }) => {
const sb = createSupabaseClient();
await sb.storage.from("autumn").remove([path]);
const { data, error } = await sb.storage
.from("autumn")
.createSignedUploadUrl(path, {
upsert: true,
});
if (error) {
throw error;
}
return data;
};

View File

@@ -1,29 +1,32 @@
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
import { createSupabaseClient } from "../supabaseUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { safeSb } from "./safeSb.js";
export const subscribeToOrgUpdates = ({ db }: { db: DrizzleCli }) => {
try {
const sb = createSupabaseClient();
sb.channel("table-db-changes")
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "organizations",
},
async (payload) => {
// await clearOrgCache(payload.new.id);
try {
await clearOrgCache({ db, orgId: payload.new.id });
} catch (error) {
console.warn("Error clearing org cache:", error);
}
},
)
.subscribe();
} catch (error) {
console.warn("Error subscribing to org updates:", error);
}
};
export const subscribeToOrgUpdates = safeSb({
fn: ({ db }: { db: DrizzleCli }) => {
try {
const sb = createSupabaseClient();
sb.channel("table-db-changes")
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "organizations",
},
async (payload) => {
try {
await clearOrgCache({ db, orgId: payload.new.id });
} catch (error) {
console.warn("Error clearing org cache:", error);
}
},
)
.subscribe();
} catch (error) {
console.warn("Error subscribing to org updates:", error);
}
},
action: "subscribe to org updates",
});

68
server/src/external/svix/svixHelpers.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import { AppEnv, Organization } from "@autumn/shared";
import { createSvixCli, getSvixAppId, safeSvix } from "./svixUtils.js";
export const createSvixApp = safeSvix({
fn: async ({
name,
orgId,
env,
}: {
name: string;
orgId: string;
env: AppEnv;
}) => {
const svix = createSvixCli();
const app = await svix.application.create({
name,
metadata: {
org_id: orgId,
env,
},
});
return app;
},
action: "createSvixApp",
});
export const deleteSvixApp = safeSvix({
fn: async ({ appId }: { appId: string }) => {
const svix = createSvixCli();
await svix.application.delete(appId);
},
action: "deleteSvixApp",
});
export const sendSvixEvent = safeSvix({
fn: async ({
org,
env,
eventType,
data,
}: {
org: Organization;
env: AppEnv;
eventType: string;
data: any;
}) => {
const svix = createSvixCli();
return await svix.message.create(getSvixAppId({ org, env }), {
eventType,
payload: {
type: eventType,
data,
},
});
},
action: "sendSvixEvent",
});
export const getSvixDashboardUrl = safeSvix({
fn: async ({ org, env }: { org: Organization; env: AppEnv }) => {
const appId = getSvixAppId({ org, env });
const svix = createSvixCli();
const dashboard = await svix.authentication.appPortalAccess(appId, {});
return dashboard.url;
},
action: "getSvixDashboardUrl",
});

View File

@@ -1,11 +1,32 @@
import { AppEnv } from "@autumn/shared";
import { Organization } from "@autumn/shared";
import { Svix } from "svix";
import { logger } from "../logtail/logtailUtils.js";
export const createSvixCli = () => {
return new Svix(process.env.SVIX_API_KEY as string);
};
export function safeSvix<T extends (...args: any[]) => any>({
fn,
action,
}: {
fn: T;
action: string;
}): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
return async (...args: Parameters<T>) => {
if (!process.env.SVIX_API_KEY) {
logger.warn(`SVIX_API_KEY is not set, skipping ${action}`);
return;
}
try {
return await fn(...args);
} catch (error) {
logger.error(`Error ${action}: ${error}`);
}
};
}
export const getSvixAppId = ({
org,
env,
@@ -18,63 +39,3 @@ export const getSvixAppId = ({
? svixConfig.live_app_id
: svixConfig.sandbox_app_id;
};
export const createSvixApp = async ({
name,
orgId,
env,
}: {
name: string;
orgId: string;
env: AppEnv;
}) => {
const svix = createSvixCli();
const app = await svix.application.create({
name,
metadata: {
org_id: orgId,
env,
},
});
return app;
};
export const deleteSvixApp = async ({ appId }: { appId: string }) => {
const svix = createSvixCli();
await svix.application.delete(appId);
};
export const sendSvixEvent = async ({
org,
env,
eventType,
data,
}: {
org: Organization;
env: AppEnv;
eventType: string;
data: any;
}) => {
const svix = createSvixCli();
return await svix.message.create(getSvixAppId({ org, env }), {
eventType,
payload: {
type: eventType,
data,
},
});
};
export const getSvixDashboardUrl = async ({
org,
env,
}: {
org: Organization;
env: AppEnv;
}) => {
const appId = getSvixAppId({ org, env });
const svix = createSvixCli();
const dashboard = await svix.authentication.appPortalAccess(appId, {});
return dashboard.url;
};

View File

@@ -1,59 +0,0 @@
// import { AppEnv } from "@autumn/shared";
// import { Unkey } from "@unkey/api";
// const UNKEY_API_ID = "api_2fcMv43jiAbBySAgDubovfpVUABP";
// const createUnkeyCli = () => {
// return new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });
// };
// export const createKey = async ({
// env,
// name,
// ownerId,
// prefix,
// meta,
// }: {
// env: AppEnv;
// name: string;
// ownerId: string;
// prefix: string;
// meta: any;
// }) => {
// const unkey = createUnkeyCli();
// const key = await unkey.keys.create({
// apiId: UNKEY_API_ID,
// name,
// prefix,
// ownerId,
// meta,
// environment: env,
// });
// return key;
// };
// export const updateKey = async (keyId: string, meta: any) => {
// const unkey = createUnkeyCli();
// await unkey.keys.update({
// keyId,
// meta,
// });
// };
// export const deleteKey = async (keyId: string) => {
// const unkey = createUnkeyCli();
// await unkey.keys.delete({ keyId });
// };
// export const validateApiKey = async (apiKey: string) => {
// const unkey = createUnkeyCli();
// const { result, error } = await unkey.keys.verify({
// apiId: UNKEY_API_ID,
// key: apiKey,
// });
// if (error || !result.valid) {
// throw new Error("Invalid API key");
// }
// return result;
// };

View File

@@ -2,249 +2,96 @@ import { Request, Response } from "express";
import { Webhook } from "svix";
import { OrgService } from "@/internal/orgs/OrgService.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { createClerkCli } from "../clerkUtils.js";
import { sendOnboardingEmail } from "./sendOnboardingEmail.js";
import { AppEnv } from "autumn-js";
import { deleteSvixApp } from "../svix/svixUtils.js";
import {
deleteStripeWebhook,
initOrgSvixApps,
} from "@/internal/orgs/orgUtils.js";
import { deleteSvixApp } from "@/external/svix/svixHelpers.js";
import { deleteStripeWebhook } from "@/internal/orgs/orgUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { constructOrg } from "@/internal/orgs/orgUtils.js";
import { createOnboardingProducts } from "@/internal/orgs/onboarding/createOnboardingProducts.js";
import { eq } from "drizzle-orm";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { Organization, organizations } from "@autumn/shared";
const verifyClerkWebhook = async (req: Request, res: Response) => {
const wh = new Webhook(process.env.CLERK_SIGNING_SECRET!);
// const verifyClerkWebhook = async (req: Request, res: Response) => {
// const wh = new Webhook(process.env.CLERK_SIGNING_SECRET!);
const headers = req.headers;
const payload = req.body;
// const headers = req.headers;
// const payload = req.body;
const svix_id = headers["svix-id"];
const svix_timestamp = headers["svix-timestamp"];
const svix_signature = headers["svix-signature"];
// const svix_id = headers["svix-id"];
// const svix_timestamp = headers["svix-timestamp"];
// const svix_signature = headers["svix-signature"];
if (!svix_id || !svix_timestamp || !svix_signature) {
res.status(400).json({
success: false,
message: "Error: Missing svix headers",
});
return;
}
// if (!svix_id || !svix_timestamp || !svix_signature) {
// res.status(400).json({
// success: false,
// message: "Error: Missing svix headers",
// });
// return;
// }
let evt: any;
try {
evt = wh.verify(payload, {
"svix-id": svix_id as string,
"svix-timestamp": svix_timestamp as string,
"svix-signature": svix_signature as string,
});
} catch (err) {
console.log("Error: Could not verify webhook");
res.status(400).json({
success: false,
message: "Error: Could not verify webhook",
});
return;
}
// let evt: any;
// try {
// evt = wh.verify(payload, {
// "svix-id": svix_id as string,
// "svix-timestamp": svix_timestamp as string,
// "svix-signature": svix_signature as string,
// });
// } catch (err) {
// console.log("Error: Could not verify webhook");
// res.status(400).json({
// success: false,
// message: "Error: Could not verify webhook",
// });
// return;
// }
return evt;
};
// return evt;
// };
export const handleClerkWebhook = async (req: any, res: any) => {
let event = await verifyClerkWebhook(req, res);
// export const handleClerkWebhook = async (req: any, res: any) => {
// let event = await verifyClerkWebhook(req, res);
if (!event) {
return;
}
// if (!event) {
// return;
// }
const eventType = event.type;
const eventData = event.data;
// const eventType = event.type;
// const eventData = event.data;
try {
switch (eventType) {
case "organization.created":
await saveOrgToDB({
db: req.db,
id: eventData.id,
slug: eventData.slug,
});
break;
// try {
// switch (eventType) {
// case "organization.created":
// await saveOrgToDB({
// db: req.db,
// id: eventData.id,
// slug: eventData.slug,
// createdAt: eventData.created_at,
// });
// break;
case "organization.deleted":
await handleOrgDeleted({
db: req.db,
eventData,
});
break;
// case "organization.deleted":
// await handleOrgDeleted({
// db: req.db,
// eventData,
// });
// break;
default:
break;
}
} catch (error) {
handleRequestError({
req,
error,
res,
action: "Handle Clerk Webhook",
});
return;
}
// default:
// break;
// }
// } catch (error) {
// handleRequestError({
// req,
// error,
// res,
// action: "Handle Clerk Webhook",
// });
// return;
// }
return void res.status(200).json({
success: true,
message: "Webhook received",
});
};
export const saveOrgToDB = async ({
db,
id,
slug,
}: {
db: DrizzleCli;
id: string;
slug: string;
}) => {
console.log(`Handling organization.created: ${slug} (${id})`);
try {
// 2. Insert org
await OrgService.insert({
db,
org: constructOrg({
id,
slug,
}),
});
// 1. Create svix webhoooks
const { sandboxApp, liveApp } = await initOrgSvixApps({
slug,
id,
});
await OrgService.update({
db,
orgId: id,
updates: {
svix_config: { sandbox_app_id: sandboxApp.id, live_app_id: liveApp.id },
},
});
console.log(`Created svix webhooks for org ${id}`);
} catch (error: any) {
if (error?.data && error.data.code == "23505") {
console.error(
`Org ${id} already exists in Supabase -- skipping creationg`,
);
return;
}
console.error(
`Failed to insert org. Code: ${error.code}, message: ${error.message}`,
);
return;
}
const batch = [];
try {
// batch.push(
// createOnboardingProducts({
// db,
// orgId: eventData.id,
// }),
// );
batch.push(
sendOnboardingEmail({
orgId: id,
clerkCli: createClerkCli(),
}),
);
await Promise.all(batch);
} catch (error) {
console.error(
"Failed to create default products or send onboarding email",
error,
);
}
};
const handleOrgDeleted = async ({
db,
eventData,
}: {
db: DrizzleCli;
eventData: any;
}) => {
// 1. Delete svix webhooks
try {
console.log(`Handling organization.deleted: (${eventData.id})`);
const org = (await db.query.organizations.findFirst({
where: eq(organizations.id, eventData.id),
})) as unknown as Organization;
if (!org) {
throw new RecaseError({
message: `Clerk webhook, tried deleting org ${eventData.slug} but not found`,
code: "org_not_found",
statusCode: 404,
});
}
console.log("1. Deleting svix webhooks");
const batch = [];
if (org.svix_config?.sandbox_app_id) {
batch.push(
deleteSvixApp({
appId: org.svix_config.sandbox_app_id,
}),
);
}
if (org.svix_config?.live_app_id) {
batch.push(
deleteSvixApp({
appId: org.svix_config.live_app_id,
}),
);
}
await Promise.all(batch);
// 2. Delete stripe webhooks
console.log("2. Deleting stripe webhooks");
if (org.stripe_config) {
await deleteStripeWebhook({
org: org,
env: AppEnv.Sandbox,
});
await deleteStripeWebhook({
org: org,
env: AppEnv.Live,
});
}
// 3. Delete org
console.log("3. Deleting org");
await OrgService.delete({
db,
orgId: eventData.id,
});
console.log(`Deleted org ${org.slug} (${org.id})`);
} catch (error) {
console.log("Failed to delete organization", error);
return;
}
};
// return void res.status(200).json({
// success: true,
// message: "Webhook received",
// });
// };

View File

@@ -1,50 +0,0 @@
import { ClerkClient } from "@clerk/express";
import { sendHtmlEmail, sendTextEmail } from "../resend/resendUtils.js";
const getWelcomeEmailBody = (userFirstName: string) => {
return `
<p>Hey ${userFirstName} :)</p>
<p>Just wanted to say thank you for signing up to Autumn!</p>
<p>I'm curious--how did you hear about us? Also are you just looking around or do you have a specific use case I can help you with?</p>
<p>Whatever the reason, anything you need I'm here to help.</p>
<p>Ayush<br>
Co-founder, Autumn</p>
<p>Oh, and join our <a href="https://discord.gg/STqxY92zuS">Discord community</a> to connect with us and other users</p>
`;
};
export const sendOnboardingEmail = async ({
orgId,
clerkCli,
}: {
orgId: string;
clerkCli: ClerkClient;
}) => {
const memberships =
await clerkCli.organizations.getOrganizationMembershipList({
organizationId: orgId,
});
for (let membership of memberships.data) {
if (!membership.publicUserData) break;
const user = await clerkCli.users.getUser(membership.publicUserData.userId);
const email = user.primaryEmailAddress?.emailAddress;
if (!email) break;
console.log("Sending onboarding email to", email);
await sendHtmlEmail({
to: email,
subject: "Anything I can help with?",
body: getWelcomeEmailBody(user.firstName ?? "there"),
});
break;
}
};

View File

@@ -1,19 +1,12 @@
import express from "express";
import bodyParser from "body-parser";
import { handleClerkWebhook } from "./clerkWebhooks.js";
import express, { Router } from "express";
import { stripeWebhookRouter } from "../stripe/stripeWebhooks.js";
import { autumnWebhookRouter } from "../autumn/autumnWebhookRouter.js";
const webhooksRouter = express.Router();
const webhooksRouter: Router = express.Router();
webhooksRouter.use("/stripe", stripeWebhookRouter);
webhooksRouter.use("/autumn", autumnWebhookRouter);
webhooksRouter.post(
"/clerk",
bodyParser.raw({ type: "application/json" }),
handleClerkWebhook
);
export default webhooksRouter;

View File

@@ -8,56 +8,75 @@ import mainRouter from "./internal/mainRouter.js";
import express from "express";
import cors from "cors";
import chalk from "chalk";
import { apiRouter } from "./internal/api/apiRouter.js";
import http from "http";
import webhooksRouter from "./external/webhooks/webhooksRouter.js";
import { initLogger } from "./errors/logger.js";
import { apiRouter } from "./internal/api/apiRouter.js";
import { QueueManager } from "./queue/QueueManager.js";
import { AppEnv } from "@autumn/shared";
import { createSupabaseClient } from "./external/supabaseUtils.js";
import {
createLogtail,
createLogtailAll,
} from "./external/logtail/logtailUtils.js";
import { createLogtail } from "./external/logtail/logtailUtils.js";
import { CacheManager } from "./external/caching/CacheManager.js";
import { initDrizzle } from "./db/initDrizzle.js";
import { createPosthogCli } from "./external/posthog/createPosthogCli.js";
import { logtailAll, logger } from "./external/logtail/logtailUtils.js";
import { createPosthogCli } from "./external/posthog/createPosthogCli.js";
import { generateId } from "./utils/genUtils.js";
import { subscribeToOrgUpdates } from "./external/supabase/subscribeToOrgUpdates.js";
import { client, db } from "./db/initDrizzle.js";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./utils/auth.js";
import { checkEnvVars } from "./utils/initUtils.js";
if (!process.env.DATABASE_URL) {
console.error(`DATABASE_URL is not set`);
process.exit(1);
}
const { db, client } = initDrizzle({ maxConnections: 10 });
checkEnvVars();
const init = async () => {
const app = express();
const logger = initLogger();
// Check if this blocks API calls...
app.use(
cors({
origin: [
"http://localhost:3000",
"https://app.useautumn.com",
"https://*.useautumn.com",
process.env.CLIENT_URL || "",
],
credentials: true,
allowedHeaders: [
"app_env",
"x-api-version",
"Authorization",
"Content-Type",
"Accept",
"Origin",
"X-API-Version",
"X-Requested-With",
"Access-Control-Request-Method",
"Access-Control-Request-Headers",
"Cache-Control",
"If-Match",
"If-None-Match",
"If-Modified-Since",
"If-Unmodified-Since",
],
}),
);
app.all("/api/auth/*", toNodeHandler(auth));
const server = http.createServer(app);
const posthog = createPosthogCli();
server.keepAliveTimeout = 120000; // 120 seconds
server.headersTimeout = 120000; // 120 seconds should be >= keepAliveTimeout
await QueueManager.getInstance(); // initialize the queue manager
await CacheManager.getInstance();
const supabaseClient = createSupabaseClient();
// Optional services
const logtailAll = createLogtailAll();
const posthog = createPosthogCli();
subscribeToOrgUpdates({ db });
app.use((req: any, res: any, next: any) => {
req.sb = supabaseClient;
req.db = db;
req.logger = logger;
req.logtailAll = logtailAll;
req.env = req.env = req.headers["app_env"] || AppEnv.Sandbox;
req.db = db;
req.logtailAll = logtailAll;
req.posthog = posthog;
@@ -69,7 +88,7 @@ const init = async () => {
headersClone.authorization = undefined;
headersClone.Authorization = undefined;
logtailAll.info(`${req.method} ${req.originalUrl}`, {
logtailAll?.info(`${req.method} ${req.originalUrl}`, {
url: req.originalUrl,
method: req.method,
headers: headersClone,
@@ -77,9 +96,8 @@ const init = async () => {
});
req.logtail = createLogtail();
req.logger = req.logtail;
} catch (error) {
req.logtail = logtailAll; // fallback
req.logtail = logger; // fallback
console.error(`Error creating req.logtail`);
console.error(error);
}
@@ -91,8 +109,6 @@ const init = async () => {
next();
});
app.use(cors());
app.use("/webhooks", webhooksRouter);
app.use((req: any, res, next) => {
@@ -142,14 +158,7 @@ if (process.env.NODE_ENV === "development") {
}
cluster.on("exit", (worker, code, signal) => {
try {
let logtail = createLogtail();
logtail.error(`WORKER DIED: ${worker.process.pid}`);
logtail.flush();
} catch (error) {
console.log("Error sending log to logtail", error);
}
// LOG in Render
logger.error(`WORKER DIED: ${worker.process.pid}`);
cluster.fork();
});
} else {
@@ -175,3 +184,21 @@ async function gracefulShutdown() {
process.exit(1);
}
}
// Close connections gracefully?
const closeConnections = async () => {
console.log("Closing connections");
await client.end();
};
process.on("SIGTERM", async () => {
console.log("SIGTERM received, shutting down gracefully");
await closeConnections();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("SIGINT received, shutting down gracefully");
await closeConnections();
process.exit(0);
});

View File

@@ -0,0 +1,158 @@
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { member, organizations, user } from "@autumn/shared";
import { and, desc, eq, gt, gte, ilike, inArray, lt, or } from "drizzle-orm";
import { Router } from "express";
export const adminRouter: Router = Router();
adminRouter.get("/users", async (req: any, res: any) => {
try {
const { db } = req as ExtendedRequest;
let { sortKey, search, after, before } = req.query;
if (after) {
after = {
id: after.split(",")[0],
createdAt: new Date(after.split(",")[1]),
};
} else if (before) {
before = {
id: before.split(",")[0],
createdAt: new Date(before.split(",")[1]),
};
}
const users = await db
.select()
.from(user)
.where(
and(
search
? or(
ilike(user.email, `%${search as string}%`),
ilike(user.name, `%${search as string}%`),
ilike(user.id, `%${search as string}%`),
)
: undefined,
after
? or(
lt(user.createdAt, after.createdAt),
or(
and(
eq(user.createdAt, after.createdAt),
lt(user.id, after.id),
),
),
)
: undefined,
before
? or(
gte(user.createdAt, before.createdAt),
or(
and(
eq(user.createdAt, before.createdAt),
gt(user.id, before.id),
),
),
)
: undefined,
),
)
.orderBy(desc(user.createdAt), desc(user.id))
.limit(21);
res.json({
rows: users.slice(0, 20),
hasNextPage: users.length > 20,
});
} catch (error) {
handleFrontendReqError({
res,
req,
error,
action: "admin: search users",
});
}
});
adminRouter.get("/orgs", async (req: any, res: any) => {
try {
const { db } = req as ExtendedRequest;
let { search, after, before } = req.query;
if (after) {
after = {
id: after.split(",")[0],
createdAt: new Date(after.split(",")[1]),
};
} else if (before) {
before = {
id: before.split(",")[0],
createdAt: new Date(before.split(",")[1]),
};
}
const orgs = await db
.select()
.from(organizations)
.where(
and(
search
? or(ilike(organizations.name, `%${search as string}%`))
: undefined,
after
? or(
lt(organizations.createdAt, after.createdAt),
or(
and(
eq(organizations.createdAt, after.createdAt),
lt(organizations.id, after.id),
),
),
)
: undefined,
before
? or(
gte(organizations.createdAt, before.createdAt),
or(
and(
eq(organizations.createdAt, before.createdAt),
gt(organizations.id, before.id),
),
),
)
: undefined,
),
)
.orderBy(desc(organizations.createdAt), desc(organizations.id))
.limit(21);
let orgIds = orgs.map((org) => org.id);
let memberships = await db
.select()
.from(member)
.leftJoin(user, eq(member.userId, user.id))
.where(inArray(member.organizationId, orgIds));
res.json({
rows: orgs.slice(0, 20).map((org) => ({
...org,
users: memberships
.filter((membership) => membership.member.organizationId === org.id)
.map((membership) => membership.user?.email),
})),
hasNextPage: orgs.length > 20,
});
} catch (error) {
handleFrontendReqError({
res,
req,
error,
action: "admin: search orgs",
});
}
});

View File

@@ -0,0 +1,14 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { user } from "@autumn/shared";
import { count } from "drizzle-orm";
// Required stats (by interval):
// 1. User count
// 2. Retained count
// 3. Churned count
export const getUserCount = async ({ db }: { db: DrizzleCli }) => {
const userCount = await db.select({ count: count() }).from(user);
return userCount[0].count;
};

View File

@@ -0,0 +1,34 @@
import { ErrCode } from "@/errors/errCodes.js";
import { auth } from "@/utils/auth.js";
import { ADMIN_USER_IDs } from "@/utils/constants.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { NextFunction } from "express";
export const withAdminAuth = async (req: any, res: any, next: NextFunction) => {
const { logtail: logger, userId } = req as ExtendedRequest;
try {
const data = await auth.api.getSession({
headers: req.headers,
});
if (
!ADMIN_USER_IDs.includes(data?.session?.userId || "") &&
!ADMIN_USER_IDs.includes(data?.session?.impersonatedBy || "")
) {
return res.status(403).json({
error: {
code: ErrCode.InvalidRequest,
message: "Method not allowed",
},
});
}
console.log("Admin auth passed");
next();
} catch (error: any) {
logger.error(`Admin req failed: ${error.message}`);
return res.status(400).json();
}
};

View File

@@ -8,7 +8,7 @@ import {
Organization,
} from "@autumn/shared";
import { sendSvixEvent } from "../../../external/svix/svixUtils.js";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getCustomerDetails } from "@/internal/customers/cusUtils/getCustomerDetails.js";
@@ -205,7 +205,7 @@ export const handleProductsUpdated = async ({
}
// 2. Send Svix event
const res = await sendSvixEvent({
await sendSvixEvent({
org,
env,
eventType: "customer.products.updated",

View File

@@ -21,18 +21,12 @@ import { analyticsMiddleware } from "@/middleware/analyticsMiddleware.js";
import rewardRouter from "./rewards/rewardRouter.js";
import expireRouter from "../customers/expire/expireRouter.js";
const apiRouter = Router();
const apiRouter: Router = Router();
apiRouter.use(apiAuthMiddleware);
apiRouter.use(pricingMiddleware);
apiRouter.use(analyticsMiddleware);
apiRouter.get("/auth", (req: any, res) => {
res.json({
message: `Authenticated -- Hello ${req.minOrg?.slug}!`,
});
});
apiRouter.use("/customers", cusRouter);
apiRouter.use("/invoices", invoiceRouter);
apiRouter.use("/products", productApiRouter);

View File

@@ -11,7 +11,7 @@ import { routeHandler } from "@/utils/routerUtils.js";
import { FullCusProduct, CusProductStatus, ProductV2 } from "@autumn/shared";
import { Router } from "express";
export const componentRouter = Router();
export const componentRouter: Router = Router();
componentRouter.get("/pricing_table", async (req: any, res) =>
routeHandler({

View File

@@ -20,9 +20,8 @@ import { handleUpdateCustomer } from "../customers/handlers/handleUpdateCustomer
import { handleCreateBillingPortal } from "../customers/handlers/handleCreateBillingPortal.js";
import { handleGetCustomer } from "../customers/handlers/handleGetCustomer.js";
import { CusSearchService } from "@/internal/customers/CusSearchService.js";
import assert from "assert";
export const cusRouter = Router();
export const cusRouter: Router = Router();
cusRouter.post("/all/search", async (req: any, res: any) => {
try {

View File

@@ -1,18 +1,15 @@
import { Router } from "express";
import { routeHandler } from "@/utils/routerUtils.js";
import { EntityService } from "./EntityService.js";
import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@autumn/shared";
import { handleGetEntity } from "./handlers/handleGetEntity.js";
import { handlePostEntityRequest } from "../../entities/handlers/handleCreateEntity/handleCreateEntity.js";
import { handleDeleteEntity } from "./handlers/handleDeleteEntity.js";
export const entityRouter = Router({ mergeParams: true });
export const entityRouter: Router = Router({ mergeParams: true });
// List entityes
entityRouter.get("", (req, res) =>
entityRouter.get("", (req: any, res: any) =>
routeHandler({
req,
res,

View File

@@ -30,7 +30,7 @@ import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCu
import { getCheckPreview } from "./getCheckPreview.js";
import { orgToVersion } from "@/utils/versionUtils.js";
export const entitledRouter = Router();
export const entitledRouter: Router = Router();
const getRequiredAndActualBalance = ({
cusEnts,

View File

@@ -30,7 +30,7 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { getEventTimestamp } from "./eventUtils.js";
export const eventsRouter = Router();
export const eventsRouter: Router = Router();
const getEventAndCustomer = async ({
req,

View File

@@ -3,7 +3,6 @@ import {
CusProductStatus,
Customer,
ErrCode,
Event,
EventInsert,
FeatureType,
FeatureUsageType,
@@ -12,22 +11,17 @@ import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { generateId, nullish } from "@/utils/genUtils.js";
import { EventService } from "./EventService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { StatusCodes } from "http-status-codes";
import { QueueManager } from "@/queue/QueueManager.js";
import { JobName } from "@/queue/JobName.js";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js";
import { creditSystemContainsFeature } from "@/internal/features/creditSystemUtils.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getOrgAndFeatures } from "@/internal/orgs/orgUtils.js";
import { getEventTimestamp } from "./eventUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { runUpdateBalanceTask } from "@/trigger/updateBalanceTask.js";
import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js";
export const eventsRouter = Router();
export const usageRouter = Router();
export const eventsRouter: Router = Router();
export const usageRouter: Router = Router();
const getCusFeatureAndOrg = async ({
req,

View File

@@ -6,7 +6,7 @@ import RecaseError, {
import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
import { Feature, FeatureResponseSchema, FeatureType } from "@autumn/shared";
import { CreateFeatureSchema } from "@autumn/shared";
import express from "express";
import express, { Router } from "express";
import { generateId } from "@/utils/genUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
@@ -20,7 +20,7 @@ import { JobName } from "@/queue/JobName.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js";
export const featureApiRouter = express.Router();
export const featureApiRouter: Router = express.Router();
export const validateFeature = (data: any) => {
let featureType = data.type;

View File

@@ -4,7 +4,7 @@ import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { Router } from "express";
export const invoiceRouter = Router();
export const invoiceRouter: Router = Router();
invoiceRouter.get("/:stripe_invoice_id/stripe", async (req: any, res: any) => {
try {

View File

@@ -17,7 +17,7 @@ import { handleCopyProduct } from "./handlers/handleCopyProduct.js";
import { handleCreateProduct } from "./handlers/handleCreateProduct.js";
import { handleListProducts } from "./handlers/handleListProducts.js";
export const productApiRouter = Router();
export const productApiRouter: Router = Router();
productApiRouter.get("", handleListProducts);

View File

@@ -8,13 +8,13 @@ import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionServ
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { Customer, ErrCode, RewardTriggerEvent } from "@autumn/shared";
import express from "express";
import { ReferralCode, RewardRedemption } from "@autumn/shared";
import { ErrCode, RewardTriggerEvent } from "@autumn/shared";
import express, { Router } from "express";
import { RewardRedemption } from "@autumn/shared";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
export const referralRouter = express.Router();
export const referralRouter: Router = express.Router();
// 1. Get referral code
referralRouter.post("/code", (req, res) =>
@@ -238,7 +238,7 @@ referralRouter.post("/redeem", (req, res) =>
}),
);
export const redemptionRouter = express.Router();
export const redemptionRouter: Router = express.Router();
redemptionRouter.get("/:redemptionId", (req, res) =>
routeHandler({

View File

@@ -8,9 +8,9 @@ import {
ErrCode,
RewardTriggerEvent,
} from "@autumn/shared";
import express from "express";
import express, { Router } from "express";
export const rewardProgramRouter = express.Router();
export const rewardProgramRouter: Router = express.Router();
rewardProgramRouter.post("", (req, res) =>
routeHandler({

View File

@@ -1,4 +1,4 @@
import express from "express";
import express, { Router } from "express";
import {
CreateRewardSchema,
ErrCode,
@@ -19,7 +19,7 @@ import {
getRewardCat,
} from "@/internal/rewards/rewardUtils.js";
const rewardRouter = express.Router();
const rewardRouter: Router = express.Router();
rewardRouter.post("", async (req: any, res: any) => {
try {

View File

@@ -189,194 +189,6 @@ export class CusService {
return customer as Customer;
}
//search customers
static addPaginationAndSearch = ({
query,
search,
pageNumber,
pageSize,
lastItem,
customerPrefix = "",
}: {
query: any;
search: string;
pageNumber: number | null;
pageSize: number;
lastItem: any;
customerPrefix: string;
}) => {
if (search && search !== "") {
query.or(
`"name".ilike.%${search}%, ` +
`"email".ilike.%${search}%, ` +
`"id".ilike.%${search}%`,
customerPrefix && {
foreignTable: "customers",
referencedTable: "customers",
},
);
}
if (lastItem) {
query.or(
`"internal_id".lt.${lastItem.internal_id}`,
// `"created_at".lt.${lastItem.created_at},` +
// `and("created_at".eq.${lastItem.created_at},"internal_id".gt.${lastItem.internal_id})`,
customerPrefix && {
foreignTable: "customers",
referencedTable: "customers",
},
);
}
if (customerPrefix) {
query.order(`customer(internal_id)`, { ascending: false });
} else {
query.order("internal_id", { ascending: false });
// query
// .order("created_at", { ascending: false })
// .order("internal_id", { ascending: true });
}
query.limit(pageSize);
};
static async searchCustomersByProduct({
sb,
orgId,
env,
search,
filters,
pageSize,
lastItem,
pageNumber,
}: {
sb: SupabaseClient;
orgId: string;
env: AppEnv;
search: string;
filters: any;
pageSize: number;
lastItem: any;
pageNumber: number;
}) {
const query = sb
.from("customer_products")
.select(
`*,
customer:customers!inner(*), product:products!inner(id, name, version)`,
{
count: "exact",
},
)
.eq("customer.org_id", orgId)
.eq("customer.env", env)
.in("status", [CusProductStatus.Active, CusProductStatus.PastDue]);
if (filters.product_id) {
query.eq("product.id", filters.product_id);
}
if (filters?.status === "canceled") {
console.log("Adding canceled filter");
query
.eq("status", CusProductStatus.Active)
.not("canceled_at", "is", null);
} else if (filters?.status === "free_trial") {
console.log("Adding free trial filter");
query
.eq("status", CusProductStatus.Active)
.gt("trial_ends_at", Date.now());
}
this.addPaginationAndSearch({
query,
search,
pageNumber,
pageSize,
lastItem,
customerPrefix: "customers.",
});
const { data, count, error } = await query;
if (error) {
throw error;
}
// Flip
const customers = flipProductResults(data);
return { data: customers, count };
}
static async searchCustomers({
sb,
orgId,
env,
search,
pageSize = 50,
filters,
lastItem,
pageNumber,
}: {
sb: SupabaseClient;
orgId: string;
env: AppEnv;
search: string;
lastItem?: { created_at: string; name: string; internal_id: string } | null;
filters: any;
pageSize?: number;
pageNumber: number;
}) {
if (filters.product_id || filters.status) {
return await this.searchCustomersByProduct({
sb,
orgId,
env,
search,
filters,
pageSize,
lastItem,
pageNumber,
});
}
let select =
"*, customer_products:customer_products(*, product:products(*))";
let query = sb
.from("customers")
.select(select, {
count: "exact",
// count: "planned", // use for 1M rows...?
})
.eq("org_id", orgId)
.eq("env", env);
this.addPaginationAndSearch({
query,
search,
pageNumber: null,
pageSize,
lastItem,
customerPrefix: "",
});
const { data, count, error } = await query;
if (error) {
throw error;
}
const totalCount = count && count + pageSize * (pageNumber - 1);
return { data, count: totalCount };
}
// End of search customers
static async insert({ db, data }: { db: DrizzleCli; data: Customer }) {
try {
const results = await db
@@ -469,192 +281,3 @@ export class CusService {
return results;
}
}
// static async getWithProductsDrizzle({
// db,
// idOrInternalId,
// orgId,
// env,
// inStatuses = [
// CusProductStatus.Active,
// CusProductStatus.PastDue,
// CusProductStatus.Scheduled,
// ],
// withEntities = false,
// entityId,
// expand,
// withSubs = false,
// }: {
// db: DrizzleCli;
// idOrInternalId: string;
// orgId: string;
// env: AppEnv;
// inStatuses?: CusProductStatus[];
// withEntities?: boolean;
// entityId?: string;
// expand?: (CusExpand | EntityExpand)[];
// withSubs?: boolean;
// }) {
// // 1. Call RPC function
// let data: {
// customer: Customer | null;
// products: FullCusProduct[] | null;
// entities: Entity[] | null;
// entity: Entity | null;
// trials_used: any[] | null;
// subscriptions: any[] | null;
// invoices: any[] | null;
// };
// try {
// const result = await db.execute(sql`
// SELECT * FROM get_cus_with_products(
// p_cus_id => ${idOrInternalId}::text,
// p_org_id => ${orgId}::text,
// p_env => ${env}::text,
// p_statuses => ARRAY[${sql.join(
// inStatuses.map((status) => sql`${status}`),
// sql`, `,
// )}]::text[],
// p_with_entities => ${withEntities}::boolean,
// p_entity_id => ${entityId || null}::text,
// p_with_trials_used => ${expand?.includes(CusExpand.TrialsUsed) || false}::boolean,
// p_with_subs => ${withSubs}::boolean,
// p_with_invoices => ${expand?.includes(CusExpand.Invoices) || false}::boolean
// )
// `);
// if (!result || result.length == 0 || !result[0].get_cus_with_products) {
// throw new RecaseError({
// message: "Calling get_cus_with_products RPC returned wrong shape",
// code: ErrCode.GetCusWithProductsFailed,
// statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
// data: result,
// });
// }
// data = result[0].get_cus_with_products as any;
// } catch (error) {
// throw error;
// }
// if (!data || !data.customer) {
// return null;
// }
// let { customer, products, entities, entity } = data;
// if (!products) {
// products = [];
// }
// for (let product of products) {
// if (!product.customer_prices) {
// product.customer_prices = [];
// }
// if (!product.customer_entitlements) {
// product.customer_entitlements = [];
// }
// }
// let trialsUsed = data.trials_used;
// if (trialsUsed) {
// trialsUsed = trialsUsed.filter(
// (trial: any, index: number, self: any) =>
// index ===
// self.findIndex((t: any) => t.product_id === trial.product_id),
// );
// }
// return {
// ...customer,
// customer_products: products,
// entities: entities,
// entity: entity,
// trials_used: trialsUsed,
// subscriptions: data.subscriptions,
// invoices: data.invoices,
// } as FullCustomer;
// }
// static async getWithProducts({
// sb,
// idOrInternalId,
// orgId,
// env,
// inStatuses = [
// CusProductStatus.Active,
// CusProductStatus.PastDue,
// CusProductStatus.Scheduled,
// ],
// withEntities = false,
// entityId,
// expand,
// withSubs = false,
// }: {
// sb: SupabaseClient;
// idOrInternalId: string;
// orgId: string;
// env: AppEnv;
// inStatuses?: CusProductStatus[];
// withEntities?: boolean;
// entityId?: string;
// expand?: (CusExpand | EntityExpand)[];
// withSubs?: boolean;
// }) {
// const { data, error } = await sb.rpc("get_cus_with_products", {
// p_cus_id: idOrInternalId,
// p_org_id: orgId,
// p_env: env,
// p_statuses: inStatuses,
// p_with_entities: withEntities,
// p_entity_id: entityId,
// p_with_trials_used: expand?.includes(CusExpand.TrialsUsed),
// p_with_subs: withSubs,
// p_with_invoices: expand?.includes(CusExpand.Invoices),
// });
// if (error) {
// throw error;
// }
// if (!data || !data.customer) {
// return null;
// }
// let { customer, products, entities, entity } = data;
// if (!products) {
// products = [];
// }
// for (let product of products) {
// if (!product.customer_prices) {
// product.customer_prices = [];
// }
// if (!product.customer_entitlements) {
// product.customer_entitlements = [];
// }
// }
// let trialsUsed = data.trials_used;
// if (trialsUsed) {
// trialsUsed = trialsUsed.filter(
// (trial: any, index: number, self: any) =>
// index ===
// self.findIndex((t: any) => t.product_id === trial.product_id),
// );
// }
// return {
// ...customer,
// customer_products: products,
// entities: entities,
// entity: entity,
// trials_used: trialsUsed,
// subscriptions: data.subscriptions,
// invoices: data.invoices,
// };
// }

View File

@@ -86,18 +86,18 @@ export const handleOneOffFunction = async ({
// Create invoice
logger.info("1. Creating invoice");
const stripeInvoice = await stripeCli.invoices.create({
customer: customer.processor.id,
customer: customer.processor.id!,
auto_advance: false,
currency: org.default_currency,
currency: org.default_currency!,
});
logger.info("2. Creating invoice items");
for (const invoiceItem of invoiceItems) {
await stripeCli.invoiceItems.create({
...invoiceItem,
customer: customer.processor.id,
customer: customer.processor.id!,
invoice: stripeInvoice.id,
});
} as any);
}
// Create invoice items

View File

@@ -42,7 +42,7 @@ import { processAttachBody } from "./attachUtils/attachParams/processAttachBody.
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
import { handleAttach } from "./handleAttach.js";
export const attachRouter = Router();
export const attachRouter: Router = Router();
export const handlePrepaidErrors = async ({
attachParams,
@@ -257,10 +257,6 @@ const handleAttachOld = async (req: any, res: any) =>
}
logger.info("--------------------------------");
let publicStr = req.isPublic ? "(Public) " : "";
logger.info(
`${publicStr}ATTACH PRODUCT REQUEST (from ${req.minOrg.slug})`,
);
const {
customer,

View File

@@ -6,7 +6,7 @@ import { CusProductStatus, ErrCode, FullCusProduct } from "@autumn/shared";
import { Router } from "express";
import { expireCusProduct } from "../handlers/handleCusProductExpired.js";
const expireRouter = Router();
const expireRouter: Router = Router();
expireRouter.post("", async (req, res) =>
routeHandler({

View File

@@ -40,7 +40,7 @@ export const handleGetCustomer = async (req: any, res: any) =>
if (!customer) {
req.logtail.warn(
`GET /customers/${customerId}: not found | Org: ${req.minOrg.slug}`,
`GET /customers/${customerId}: not found | Org: ${org.slug}`,
);
res.status(StatusCodes.NOT_FOUND).json({
message: `Customer ${customerId} not found`,

View File

@@ -56,9 +56,7 @@ export const handlePostCustomerRequest = async (req: any, res: any) => {
error instanceof RecaseError &&
error.code === ErrCode.DuplicateCustomerId
) {
logger.warn(
`POST /customers: ${error.message} (org: ${req.minOrg.slug})`,
);
logger.warn(`POST /customers: ${error.message} (org: ${req.org?.slug})`);
res.status(error.statusCode).json({
message: error.message,
code: error.code,

View File

@@ -82,7 +82,7 @@ export const handleUpdateBalances = async (req: any, res: any) => {
logger.info("--------------------------------");
logger.info(
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${req.minOrg.slug}`,
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
);
logger.info(
`Features to update: ${balances.map(

View File

@@ -26,7 +26,7 @@ import { StatusCodes } from "http-status-codes";
import { cusProductToProduct } from "./cusProducts/cusProductUtils/convertCusProduct.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
export const cusRouter = Router();
export const cusRouter: Router = Router();
cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
try {

View File

@@ -30,7 +30,7 @@ export const getProductChargeText = ({
itemStrs.push(
formatCurrency({
amount: total,
defaultCurrency: org.default_currency,
defaultCurrency: org.default_currency!,
}),
);
}

View File

@@ -26,17 +26,17 @@ export const formatTiers = ({
if (tiers.length == 1) {
return formatCurrency({
amount: tiers[0].amount,
defaultCurrency: org.default_currency,
defaultCurrency: org.default_currency!,
});
}
let tiersStart = formatCurrency({
amount: tiers[0].amount,
defaultCurrency: org.default_currency,
defaultCurrency: org.default_currency!,
});
let tiersEnd = formatCurrency({
amount: tiers[tiers.length - 1].amount,
defaultCurrency: org.default_currency,
defaultCurrency: org.default_currency!,
});
return `${tiersStart} - ${tiersEnd}`;
@@ -58,13 +58,13 @@ export const getItemsHtml = ({
if (pricedItems.length == 1) {
html += `<br/><p style="font-size: 1.1em;"><strong>${formatCurrency({
amount: totalAmount,
defaultCurrency: org.default_currency,
defaultCurrency: org.default_currency!,
})}</strong></p>`;
} else {
html += `<br/><ul>${itemsToHtml({ items: pricedItems })}</ul>`;
html += `<br/><p style="font-size: 1.1em;">Total: ${formatCurrency({
amount: totalAmount,
defaultCurrency: org.default_currency,
defaultCurrency: org.default_currency!,
})}</p>`;
}

View File

@@ -115,32 +115,3 @@ export class CachedKeyService {
}
}
}
// const { data, error } = await sb.rpc("verify_api_key", {
// p_hashed_key: hashedKey,
// p_env: env,
// });
// if (error) {
// throw error;
// }
// if (!data.success || !data.organization) {
// console.warn(`(warning) failed to verify secret key: ${data.error}`);
// return null;
// }
// let org = structuredClone(data.organization);
// delete org.features;
// // Add org config and api version
// org.config = OrgConfigSchema.parse(org.config || {});
// org.api_version = getApiVersion({
// createdAt: org.created_at,
// });
// return {
// org,
// features: data.organization?.features || [],
// env,
// };

View File

@@ -4,13 +4,13 @@ import { Router } from "express";
import { ApiKeyService } from "./ApiKeyService.js";
import { OrgService } from "../orgs/OrgService.js";
import { createKey } from "./api-keys/apiKeyUtils.js";
import { getSvixDashboardUrl } from "@/external/svix/svixUtils.js";
import { getSvixDashboardUrl } from "@/external/svix/svixHelpers.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { CacheManager } from "@/external/caching/CacheManager.js";
import { CacheType } from "@/external/caching/cacheActions.js";
import { routeHandler } from "@/utils/routerUtils.js";
export const devRouter = Router();
export const devRouter: Router = Router();
devRouter.get("/data", withOrgAuth, async (req: any, res) => {
try {
@@ -57,9 +57,7 @@ devRouter.post("/api_key", withOrgAuth, async (req: any, res) =>
name,
orgId,
prefix,
meta: {
org_slug: req.minOrg.slug,
},
meta: {},
});
res.status(200).json({

View File

@@ -0,0 +1,40 @@
import { sendTextEmail } from "@/external/resend/resendUtils.js";
import { safeResend } from "@/external/resend/safeResend.js";
const getInvitationEmailBody = ({
orgName,
inviteLink,
}: {
orgName: string;
inviteLink: string;
}) => {
return `Hey there! You've been invited to join ${orgName} on Autumn.
Click the link below to create an account / sign in and you'll be automatically added to the organization.
${process.env.CLIENT_URL}/sign-in
`;
};
export const sendInvitationEmail = safeResend({
fn: async ({
email,
orgName,
inviteLink,
}: {
email: string;
orgName: string;
inviteLink: string;
}) => {
console.log("Sending invitation email to", email);
await sendTextEmail({
from: `Autumn <hey@${process.env.RESEND_DOMAIN}>`,
to: email,
subject: `Join ${orgName} on Autumn`,
body: getInvitationEmailBody({ orgName, inviteLink }),
});
},
action: "send org invitation email",
});

View File

@@ -0,0 +1,49 @@
import { MigrationService } from "../migrations/MigrationService.js";
import { sendTextEmail } from "@/external/resend/resendUtils.js";
import { MigrationJobStep, Organization } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { safeResend } from "@/external/resend/safeResend.js";
export const sendMigrationEmail = safeResend({
fn: async ({
db,
migrationJobId,
org,
}: {
db: DrizzleCli;
migrationJobId: string;
org: Organization;
}) => {
let migrationJob = await MigrationService.getJob({
db,
id: migrationJobId,
});
// Send email
let getCustomersStep =
migrationJob.step_details[MigrationJobStep.GetCustomers];
let migrateStep =
migrationJob.step_details[MigrationJobStep.MigrateCustomers];
console.log("Sending migration email");
await sendTextEmail({
to: "johnyeocx@gmail.com",
subject: `Migration Job Finished -- ${migrationJob.id}`,
body: `
ORG: ${org.id}, ${org.slug}
Step: Get migration customers
1. Total customers: ${getCustomersStep?.total_customers}
2. Canceled customers: ${getCustomersStep?.canceled_customers}
Step: Migrate customers
1. Number of errors: ${migrateStep?.num_errors}
2. Failed customers:
${migrateStep?.failed_customers}
`,
});
},
action: "send migration email",
});

View File

@@ -0,0 +1,20 @@
import { logger } from "@/external/logtail/logtailUtils.js";
import { createResendCli } from "@/external/resend/resendUtils.js";
import OTPEmail from "@emails/OTPEmail.js";
const sendOTPEmail = async ({ email, otp }: { email: string; otp: string }) => {
if (!process.env.RESEND_API_KEY || !process.env.RESEND_DOMAIN) {
logger.warn(`RESEND NOT SET UP, SIGN IN OTP: ${otp}`);
return;
}
const resend = createResendCli();
await resend.emails.send({
from: `Autumn <hey@${process.env.RESEND_DOMAIN}>`,
to: email,
subject: "Your verification code for Autumn",
react: OTPEmail({ otpCode: otp }),
});
};
export default sendOTPEmail;

View File

@@ -0,0 +1,32 @@
import { sendHtmlEmail } from "@/external/resend/resendUtils.js";
import { safeResend } from "@/external/resend/safeResend.js";
const getWelcomeEmailBody = (userFirstName: string) => {
return `
<p>Hey ${userFirstName} :)</p>
<p>Just wanted to say thank you for signing up to Autumn!</p>
<p>I'm curious--how did you hear about us? Also are you just looking around or do you have a specific use case I can help you with?</p>
<p>Whatever the reason, anything you need I'm here to help.</p>
<p>Ayush<br>
Co-founder, Autumn</p>
<p>Oh, and join our <a href="https://discord.gg/STqxY92zuS">Discord community</a> to connect with us and other users</p>
`;
};
export const sendOnboardingEmail = safeResend({
fn: async ({ name, email }: { name: string; email: string }) => {
const firstName = name.split(" ")[0];
await sendHtmlEmail({
to: email,
subject: "Anything I can help with?",
body: getWelcomeEmailBody(firstName),
});
},
action: "send onboarding email",
});

View File

@@ -1,10 +1,10 @@
import express from "express";
import express, { Router } from "express";
import { FeatureService } from "./FeatureService.js";
export const featureRouter = express.Router();
export const featureRouter: Router = express.Router();
featureRouter.get("", async (req: any, res) => {
featureRouter.get("", async (req: any, res: any) => {
try {
let features = await FeatureService.getFromReq(req);
res.status(200).json({ features });

View File

@@ -159,6 +159,13 @@ export const runSaveFeatureDisplayTask = async ({
}) => {
let display;
try {
if (!process.env.ANTHROPIC_API_KEY) {
logger.warn(
"ANTHROPIC_API_KEY is not set, skipping feature display generation",
);
return;
}
logger.info(
`Generating feature display for ${feature.id} (org: ${org.slug})`,
);

View File

@@ -1,5 +1,4 @@
import dotenv from "dotenv";
dotenv.config();
import "dotenv/config";
import { orgRouter } from "./orgs/orgRouter.js";
import { Router } from "express";
@@ -13,17 +12,18 @@ import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js";
import { handlePostOrg } from "./orgs/handlers/handlePostOrg.js";
import { Autumn } from "autumn-js";
import { autumnHandler } from "autumn-js/express";
import { parseAuthHeader } from "@/utils/authUtils.js";
import { withAdminAuth } from "./admin/withAdminAuth.js";
import { adminRouter } from "./admin/adminRouter.js";
const mainRouter = Router();
const mainRouter: Router = Router();
mainRouter.get("", async (req: any, res) => {
res.status(200).json({ message: "Hello World" });
});
mainRouter.post("/organization", withAuth, handlePostOrg);
mainRouter.use("/admin", withAdminAuth, adminRouter);
mainRouter.use("/users", withAuth, userRouter);
mainRouter.use("/onboarding", withOrgAuth, onboardingRouter);
mainRouter.use("/organization", withOrgAuth, orgRouter);
mainRouter.use("/features", withOrgAuth, featureRouter);
@@ -31,34 +31,35 @@ mainRouter.use("/products", withOrgAuth, productRouter);
mainRouter.use("/dev", devRouter);
mainRouter.use("/customers", withOrgAuth, cusRouter);
mainRouter.use(
"/api/autumn",
withOrgAuth,
autumnHandler({
identify: async (req: any) => {
return {
customerId: req.org?.id,
customerData: {
name: req.org?.slug,
email: req.user?.email,
},
};
},
}),
);
// Optional...
if (process.env.AUTUMN_SECRET_KEY) {
mainRouter.use(
"/api/autumn",
withOrgAuth,
autumnHandler({
identify: async (req: any) => {
return {
customerId: req.org?.id,
customerData: {
name: req.org?.slug,
email: req.user?.email,
},
};
},
}),
);
}
mainRouter.use(
"/demo/api/autumn",
withOrgAuth,
autumnHandler({
autumn: (req: any) => {
let bearerToken = parseAuthHeader(req);
return new Autumn({
secretKey: bearerToken,
let client = new Autumn({
url: "http://localhost:8080/v1",
}) as any;
headers: req.headers,
});
return client as any;
},
identify: async (req: any) => {
return {

View File

@@ -7,7 +7,7 @@ import {
UsagePriceConfig,
} from "@autumn/shared";
import { routeHandler } from "@/utils/routerUtils.js";
import express from "express";
import express, { Router } from "express";
import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js";
import { MigrationService } from "@/internal/migrations/MigrationService.js";
import { JobName } from "@/queue/JobName.js";
@@ -20,7 +20,7 @@ import { isFreeProduct } from "@/internal/products/productUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { findPrepaidPrice } from "../products/prices/priceUtils/findPriceUtils.js";
export const migrationRouter = express.Router();
export const migrationRouter: Router = express.Router();
migrationRouter.post("", async (req: any, res: any) => {
return routeHandler({

View File

@@ -11,7 +11,7 @@ import { MigrationService } from "../MigrationService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { migrateCustomer } from "./migrateCustomer.js";
import { sendMigrationEmail } from "./sendMigrationEmail.js";
import { sendMigrationEmail } from "../../emails/sendMigrationEmail.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import { DrizzleCli } from "@/db/initDrizzle.js";

View File

@@ -1,45 +0,0 @@
import { MigrationService } from "../MigrationService.js";
import { sendTextEmail } from "@/external/resend/resendUtils.js";
import { MigrationJobStep, Organization } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
export const sendMigrationEmail = async ({
db,
migrationJobId,
org,
}: {
db: DrizzleCli;
migrationJobId: string;
org: Organization;
}) => {
let migrationJob = await MigrationService.getJob({
db,
id: migrationJobId,
});
// Send email
let getCustomersStep =
migrationJob.step_details[MigrationJobStep.GetCustomers];
let migrateStep =
migrationJob.step_details[MigrationJobStep.MigrateCustomers];
console.log("Sending migration email");
await sendTextEmail({
to: "johnyeocx@gmail.com",
subject: `Migration Job Finished -- ${migrationJob.id}`,
body: `
ORG: ${org.id}, ${org.slug}
Step: Get migration customers
1. Total customers: ${getCustomersStep?.total_customers}
2. Canceled customers: ${getCustomersStep?.canceled_customers}
Step: Migrate customers
1. Number of errors: ${migrateStep?.num_errors}
2. Failed customers:
${migrateStep?.failed_customers}
`,
});
};

View File

@@ -0,0 +1,34 @@
// import { db } from "@/db/initDrizzle.js";
// import { OrgService } from "./OrgService.js";
// import { DrizzleCli } from "@/db/initDrizzle.js";
// import { member, organizations } from "@autumn/shared";
// import { generateId } from "@/utils/genUtils.js";
// export class AuthService {
// static async createOrg({
// db,
// name,
// slug,
// userId,
// }: {
// db: DrizzleCli;
// name: string;
// slug: string;
// userId: string;
// }) {
// // 1. Create org
// await db.insert(organizations).values({
// id: generateId("org"),
// name,
// slug,
// createdAt: new Date(),
// });
// await db.insert(member).values({
// id: generateId("mem"),
// organizationId: org.id,
// userId,
// createdAt: new Date(),
// });
// }
// }

View File

@@ -1,18 +1,21 @@
import RecaseError from "@/utils/errorUtils.js";
import { and, eq } from "drizzle-orm";
import {
AppEnv,
ErrCode,
Feature,
features,
invitation,
member,
Organization,
OrgConfigSchema,
user,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { getApiVersion } from "@/utils/versionUtils.js";
import { clearOrgCache } from "./orgUtils/clearOrgCache.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { eq } from "drizzle-orm";
import { organizations, apiKeys } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
export class OrgService {
static async getFromReq(req: any) {
@@ -32,6 +35,30 @@ export class OrgService {
return await this.get({ db: req.db, orgId: req.orgId });
}
static async getMembers({ db, orgId }: { db: DrizzleCli; orgId: string }) {
const results = await db
.select()
.from(member)
.where(eq(member.organizationId, orgId))
.innerJoin(user, eq(member.userId, user.id));
return results;
}
static async getInvites({ db, orgId }: { db: DrizzleCli; orgId: string }) {
const results = await db
.select()
.from(invitation)
.where(
and(
eq(invitation.organizationId, orgId),
eq(invitation.status, "pending"),
),
);
return results;
}
// Drizzle get
static async get({ db, orgId }: { db: DrizzleCli; orgId: string }) {
const result = await db.query.organizations.findFirst({

View File

@@ -0,0 +1,109 @@
import { deleteSvixApp } from "@/external/svix/svixHelpers.js";
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AppEnv, customers, ErrCode, Organization } from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import { Response } from "express";
import { deleteStripeWebhook } from "../orgUtils.js";
const deleteSvixWebhooks = async ({
org,
logger,
}: {
org: Organization;
logger: any;
}) => {
const batch = [];
if (org.svix_config?.sandbox_app_id) {
batch.push(
deleteSvixApp({
appId: org.svix_config.sandbox_app_id,
}),
);
}
if (org.svix_config?.live_app_id) {
batch.push(
deleteSvixApp({
appId: org.svix_config.live_app_id,
}),
);
}
try {
await Promise.all(batch);
} catch (error) {
logger.error(`Failed to delete svix webhooks for ${org.id}, ${org.slug}`);
}
};
const deleteStripeWebhooks = async ({
org,
logger,
}: {
org: Organization;
logger: any;
}) => {
if (org.stripe_config) {
try {
await deleteStripeWebhook({
org: org,
env: AppEnv.Sandbox,
});
await deleteStripeWebhook({
org: org,
env: AppEnv.Live,
});
} catch (error: any) {
logger.error(
`Failed to delete stripe webhooks for ${org.id}, ${org.slug}. ${error.message})`,
);
}
}
};
export const handleDeleteOrg = async (req: ExtendedRequest, res: Response) => {
try {
const { org, db, logtail: logger } = req;
// 1. Check if any customers
let hasCustomers = await db.query.customers.findFirst({
where: eq(customers.org_id, org.id),
});
if (hasCustomers)
throw new RecaseError({
message: "Cannot delete org with production mode customers",
code: ErrCode.OrgHasCustomers,
statusCode: 400,
});
// 2. Delete svix webhooks
logger.info("1. Deleting svix webhooks");
await deleteSvixWebhooks({ org, logger });
// 3. Delete stripe webhooks
logger.info("2. Deleting stripe webhooks");
await deleteStripeWebhooks({ org, logger });
// 4. Delete all sandbox customers
logger.info("3. Deleting sandbox customers");
await db
.delete(customers)
.where(
and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)),
);
res.status(200).json({
message: "Org deleted",
});
} catch (error) {
handleFrontendReqError({
res,
error,
req,
action: "delete-org",
});
}
};

View File

@@ -0,0 +1,29 @@
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { OrgService } from "../OrgService.js";
import { auth } from "@/utils/auth.js";
export const handleGetOrgMembers = async (req: any, res: any) => {
try {
const { org, db } = req;
const orgId = org.id;
const memberships = await OrgService.getMembers({ db, orgId });
const invites = await OrgService.getInvites({ db, orgId });
res.status(200).json({
memberships,
invites,
});
// res.status(200).json({
// memberships: Array(10).fill(memberships[0]),
// invites: Array(10).fill(invites[0]),
// });
} catch (error) {
handleFrontendReqError({
req,
error,
res,
action: "get org members",
});
}
};

View File

@@ -0,0 +1,32 @@
import { logger } from "@/external/logtail/logtailUtils.js";
import { getUploadUrl } from "@/external/supabase/storageUtils.js";
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
import { ErrCode } from "@autumn/shared";
export const handleGetUploadUrl = async (req: any, res: any) => {
try {
const { org } = req;
let path = `logo/${org.id}`;
if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_KEY) {
logger.warn("Supabase storage not set up");
res.status(400).json({
message: "Supabase storage not set up",
code: ErrCode.SupabaseNotFound,
});
return;
}
const data = await getUploadUrl({ path });
res.status(200).json(data);
} catch (error) {
handleFrontendReqError({
req,
error,
res,
action: "get upload url",
});
}
};

View File

@@ -0,0 +1,48 @@
import { auth } from "@/utils/auth.js";
import {
handleFrontendReqError,
handleRequestError,
} from "@/utils/errorUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { user } from "@autumn/shared";
import { eq } from "drizzle-orm";
import { Request, Response } from "express";
export const handleInvite = async (
req: ExtendedRequest,
res: ExtendedResponse,
) => {
try {
const { email, role } = req.body;
const { org, db } = req;
const emailUser = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (emailUser) {
await auth.api.addMember({
body: {
organizationId: org.id,
userId: emailUser.id,
role: role,
},
});
res.status(200).send({
message: "User added to organization",
});
return;
}
res.status(202).send({
message: "Send invitation to user",
});
} catch (error) {
handleFrontendReqError({
req,
res,
error,
action: "handleInvite",
});
}
};

View File

@@ -1,6 +1,3 @@
import { createClerkCli } from "@/external/clerkUtils.js";
import { saveOrgToDB } from "@/external/webhooks/clerkWebhooks.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
@@ -12,41 +9,51 @@ export const handlePostOrg = async (req: any, res: any) =>
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { userId, db } = req;
const clerk = createClerkCli();
const user = await clerk.users.getUser(userId!);
console.log("userId", userId);
let userMemberships = await clerk.users.getOrganizationMembershipList({
userId: userId!,
});
// const userMemberships = await auth.api.({
// userId: userId!,
// });
let org;
// const clerk = createClerkCli();
// const user = await clerk.users.getUser(userId!);
if (userMemberships.data.length === 0) {
org = await clerk.organizations.createOrganization({
name: `${user.firstName}'s Org`,
});
// let userMemberships = await clerk.users.getOrganizationMembershipList({
// userId: userId!,
// });
// 2. Create org membership for user
await clerk.organizations.createOrganizationMembership({
organizationId: org.id,
userId: userId!,
role: "org:admin",
});
// let org;
await saveOrgToDB({
db,
id: org.id,
slug: org.slug,
});
// if (userMemberships.data.length === 0) {
// org = await clerk.organizations.createOrganization({
// name: `${user.firstName}'s Org`,
// });
console.log(`Created new org: ${org.id} (${org.slug})`);
} else {
org = userMemberships.data[0].organization;
}
// // 2. Create org membership for user
// await clerk.organizations.createOrganizationMembership({
// organizationId: org.id,
// userId: userId!,
// role: "org:admin",
// });
// await saveOrgToDB({
// db,
// id: org.id,
// slug: org.slug,
// });
// console.log(`Created new org: ${org.id} (${org.slug})`);
// } else {
// org = userMemberships.data[0].organization;
// }
res.status(200).json({
id: org.id,
slug: org.slug,
id: "123",
slug: "123",
});
// res.status(200).json({
// id: org.id,
// slug: org.slug,
// });
},
});

View File

@@ -0,0 +1,23 @@
// import { auth } from "@/utils/auth.js";
// import { handleFrontendReqError } from "@/utils/errorUtils.js";
// export const handleUpdateOrg = async (req: any, res: any) => {
// try {
// await auth.api.updateOrganization({
// data: {
// name: req.body.name,
// slug: req.body.slug,
// },
// organizationId: req.org.id,
// });
// res.status(200).json({ success: true });
// } catch (error) {
// handleFrontendReqError({
// req,
// error,
// res,
// action: "update org",
// });
// }
// };

View File

@@ -3,7 +3,6 @@ import { Router } from "express";
import { eq } from "drizzle-orm";
import { routeHandler } from "@/utils/routerUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { createClerkCli } from "@/external/clerkUtils.js";
import { AppEnv } from "@autumn/shared";
import { parseChatResultFeatures } from "./parseChatFeatures.js";
import { parseChatProducts } from "./parseChatProducts.js";
@@ -14,7 +13,7 @@ import { EntitlementService } from "@/internal/products/entitlements/Entitlement
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
export const onboardingRouter = Router();
export const onboardingRouter: Router = Router();
onboardingRouter.post("", async (req: Request, res: any) =>
routeHandler({

View File

@@ -1,23 +1,38 @@
import express, { Router } from "express";
import Stripe from "stripe";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { encryptData } from "@/utils/encryptUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { createClerkCli } from "@/external/clerkUtils.js";
import {
checkKeyValid,
createWebhookEndpoint,
} from "@/external/stripe/stripeOnboardingUtils.js";
import { encryptData } from "@/utils/encryptUtils.js";
import RecaseError, {
handleFrontendReqError,
handleRequestError,
} from "@/utils/errorUtils.js";
import express from "express";
import Stripe from "stripe";
import { OrgService } from "./OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { AppEnv } from "@autumn/shared";
import { nullish } from "@/utils/genUtils.js";
import { clearOrgCache } from "./orgUtils/clearOrgCache.js";
import { createOrgResponse } from "./orgUtils.js";
import { handleGetOrgMembers } from "./handlers/handleGetOrgMembers.js";
import { handleInvite } from "./handlers/handleInvite.js";
import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js";
import { handleDeleteOrg } from "./handlers/handleDeleteOrg.js";
export const orgRouter = express.Router();
export const orgRouter: Router = express.Router();
orgRouter.get("/members", handleGetOrgMembers);
orgRouter.get("/upload_url", handleGetUploadUrl);
orgRouter.post("/invite", handleInvite as any);
orgRouter.delete("", handleDeleteOrg as any);
orgRouter.delete("/delete-user", async (req: any, res) => {
res.status(200).json({
message: "User deleted",
});
});
orgRouter.get("", async (req: any, res) => {
try {
@@ -30,9 +45,7 @@ orgRouter.get("", async (req: any, res) => {
const org = await OrgService.getFromReq(req);
res.status(200).json({
org,
});
res.status(200).json(createOrgResponse(org));
} catch (error) {
handleRequestError({
req,
@@ -90,8 +103,6 @@ orgRouter.post("/stripe", async (req: any, res) => {
let testWebhook: Stripe.WebhookEndpoint;
let liveWebhook: Stripe.WebhookEndpoint;
try {
console.log(`Creating stripe webhook for URL: ${process.env.SERVER_URL}`);
testWebhook = await createWebhookEndpoint(
testApiKey,
AppEnv.Sandbox,
@@ -128,15 +139,6 @@ orgRouter.post("/stripe", async (req: any, res) => {
},
});
// 2. Update org in Clerk
const clerkCli = createClerkCli();
await clerkCli.organizations.updateOrganization(req.orgId, {
publicMetadata: {
stripe_connected: true,
default_currency: defaultCurrency,
},
});
res.status(200).json({
message: "Stripe connected",
});

View File

@@ -1,10 +1,10 @@
import { decryptData, generatePublishableKey } from "@/utils/encryptUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { AppEnv, ErrCode, Organization } from "@autumn/shared";
import { createSvixApp } from "@/external/svix/svixUtils.js";
import { AppEnv, ErrCode, FrontendOrg, Organization } from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "./OrgService.js";
import { FeatureService } from "../features/FeatureService.js";
import { createSvixApp } from "@/external/svix/svixHelpers.js";
export const constructOrg = ({ id, slug }: { id: string; slug: string }) => {
return {
@@ -23,33 +23,6 @@ export const constructOrg = ({ id, slug }: { id: string; slug: string }) => {
config: {} as any,
};
};
export const initOrgSvixApps = async ({
id,
slug,
}: {
id: string;
slug: string;
}) => {
const batchCreate = [];
batchCreate.push(
createSvixApp({
name: `${slug}_${AppEnv.Sandbox}`,
orgId: id,
env: AppEnv.Sandbox,
}),
);
batchCreate.push(
createSvixApp({
name: `${slug}_${AppEnv.Live}`,
orgId: id,
env: AppEnv.Live,
}),
);
const [sandboxApp, liveApp] = await Promise.all(batchCreate);
return { sandboxApp, liveApp };
};
export const deleteStripeWebhook = async ({
org,
@@ -104,13 +77,15 @@ export const initDefaultConfig = () => {
};
};
export const createOrgResponse = (org: Organization) => {
export const createOrgResponse = (org: Organization): FrontendOrg => {
return {
id: org.id,
name: org.name,
logo: org.logo,
slug: org.slug,
default_currency: org.default_currency,
stripe_connected: org.stripe_connected,
created_at: org.created_at,
default_currency: org.default_currency || "USD",
stripe_connected: org.stripe_connected || false,
created_at: new Date(org.createdAt).getTime(),
test_pkey: org.test_pkey,
live_pkey: org.live_pkey,
};

View File

@@ -17,7 +17,7 @@ import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
export const productRouter = Router({ mergeParams: true });
export const productRouter: Router = Router({ mergeParams: true });
productRouter.get("/data", async (req: any, res) => {
try {

View File

@@ -1,7 +1,6 @@
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { numberWithCommas } from "tests/utils/general/numberUtils.js";
import { getFeatureName } from "@/internal/features/utils/displayUtils.js";
import {
@@ -13,6 +12,7 @@ import {
ErrCode,
Infinite,
FullCusProduct,
numberWithCommas,
} from "@autumn/shared";
import { isPriceItem } from "../product-items/productItemUtils/getItemType.js";
import { isFeaturePriceItem } from "../product-items/productItemUtils/getItemType.js";

View File

@@ -252,17 +252,5 @@ export class RewardProgramService {
);
return result[0].count;
// const { data, error, count } = await sb
// .from("reward_redemptions")
// .select("*, reward_program:reward_programs!inner(*)", { count: "exact" })
// .eq("referral_code_id", referralCodeId)
// .eq("triggered", true);
// if (error) {
// throw error;
// }
// return count;
}
}

View File

@@ -72,41 +72,6 @@ export class RewardRedemptionService {
});
return data as any;
// let query = sb
// .from("reward_redemptions")
// .select(
// `
// *
// ${
// withRewardProgram
// ? ", reward_program:reward_programs!inner(*, reward:rewards!inner(*))"
// : ""
// }
// ${withReferralCode ? ", referral_code:referral_codes!inner(*)" : ""}
// `,
// )
// .eq("internal_customer_id", internalCustomerId);
// if (notNullish(internalRewardProgramId)) {
// query = query.eq("internal_reward_program_id", internalRewardProgramId);
// }
// if (notNullish(triggered)) {
// query = query.eq("triggered", triggered);
// }
// if (notNullish(limit)) {
// query = query.limit(limit);
// }
// const { data, error } = await query;
// if (error) {
// throw error;
// }
// return data;
}
static async getByReferrer({
@@ -142,23 +107,6 @@ export class RewardRedemptionService {
}));
return processed;
// const { data, error } = await sb
// .from("reward_redemptions")
// .select(
// `
// *, referral_code:referral_codes!inner(*)
// ${withCustomer ? ", customer:customers!inner(*)" : ""}
// `,
// )
// .eq("referral_code.internal_customer_id", internalCustomerId)
// .limit(limit);
// if (error) {
// throw error;
// }
// return data;
}
static async insert({

View File

@@ -1,23 +1,7 @@
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { Router } from "express";
export const userRouter = Router();
export const userRouter: Router = Router();
userRouter.get("", async (req: any, res) => {
const supabase = createSupabaseClient();
res.status(200).json({ userId: req.userId });
// const { data, error } = await supabase
// .from("users")
// .select("id, email, initialized")
// .eq("id", req.userId)
// .single();
// if (error) {
// console.error("Error fetching user data:", error);
// res.status(500).json({ error: error.message });
// return;
// }
// res.status(200).json(data);
});

View File

@@ -8,40 +8,44 @@ const handleResFinish = (req: any, res: any, logtailContext: any) => {
if (skipUrls.includes(req.originalUrl)) {
return;
}
req.logtailAll.info(
`[${res.statusCode}] ${req.method} ${req.originalUrl} (${req.org?.slug})`,
{
req: {
...logtailContext,
// Only log to logtailAll if it exists
if (req.logtailAll) {
req.logtailAll.info(
`[${res.statusCode}] ${req.method} ${req.originalUrl} (${req.org?.slug})`,
{
req: {
...logtailContext,
},
statusCode: res.statusCode,
res: res.locals.responseBody,
},
statusCode: res.statusCode,
res: res.locals.responseBody,
},
);
req.logtailAll.flush();
);
req.logtailAll.flush();
}
} catch (error) {
console.error("Failed to log response to logtailAll");
console.error(error);
}
// Post hog
let posthogUrls = ["/v1/attach"];
if (req.posthog && posthogUrls.includes(req.originalUrl)) {
posthogCapture({
posthog: req.posthog,
params: {
distinctId: req.org?.id,
event: `${req.method} ${req.originalUrl}`,
properties: {
authType: req.auth,
orgSlug: req.org?.slug,
statusCode: res.statusCode,
res: res.locals.responseBody,
req: req.body,
},
},
});
}
// Save to PostHog
// let posthogUrls = ["/v1/attach"];
// if (req.posthog && posthogUrls.includes(req.originalUrl)) {
// posthogCapture({
// posthog: req.posthog,
// params: {
// distinctId: req.org?.id,
// event: `${req.method} ${req.originalUrl}`,
// properties: {
// authType: req.auth,
// orgSlug: req.org?.slug,
// statusCode: res.statusCode,
// res: res.locals.responseBody,
// req: req.body,
// },
// },
// });
// }
};
export const analyticsMiddleware = async (req: any, res: any, next: any) => {

View File

@@ -8,6 +8,7 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
const authHeader =
req.headers["authorization"] || req.headers["Authorization"];
const logger = req.logtail;
const version = req.headers["x-api-version"];
if (version) {
@@ -56,7 +57,6 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
// Try verify via Autumn
let logger = req.logtail;
try {
const { valid, data } = await verifyKey({
db: req.db,

View File

@@ -1,6 +1,8 @@
import { OrgService } from "@/internal/orgs/OrgService.js";
import { AuthType } from "@autumn/shared";
import { auth } from "@/utils/auth.js";
import { AuthType, ErrCode } from "@autumn/shared";
import { verifyToken } from "@clerk/express";
import { fromNodeHeaders } from "better-auth/node";
import { NextFunction } from "express";
const getTokenData = async (req: any, res: any) => {
@@ -33,59 +35,73 @@ const getTokenData = async (req: any, res: any) => {
};
export const withOrgAuth = async (req: any, res: any, next: NextFunction) => {
try {
let tokenData = await getTokenData(req, res);
const { logtail: logger } = req;
if (!tokenData?.org_id) {
throw new Error("token data has no org_id");
try {
// let tokenData = await getTokenData(req, res);
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session) {
logger.info(`Unauthorized - no session found (${req.originalUrl})`);
return res
.status(401)
.json({ message: "Unauthorized - no session found" });
}
let tokenOrg = tokenData!.org as any;
const orgId = session?.session?.activeOrganizationId;
if (!orgId) {
logger.info(`Unauthorized - no org id found`);
return res
.status(401)
.json({ message: "Unauthorized - no org id found" });
}
// let tokenOrg = tokenData!.org as any;
let data = await OrgService.getWithFeatures({
db: req.db,
orgId: tokenOrg.id,
orgId: orgId,
env: req.env,
});
if (!data) {
return res.status(404).json({ message: "Org not found" });
logger.warn(`Org ${orgId} not found in DB`);
return res
.status(500)
.json({ message: "Org not found", code: ErrCode.OrgNotFound });
}
const { org, features } = data;
req.minOrg = {
id: tokenOrg?.id,
slug: tokenOrg?.slug,
};
req.orgId = tokenData!.org_id;
req.user = tokenData!.user;
req.user = session?.user;
req.orgId = orgId;
req.org = org;
req.features = features;
req.authType = AuthType.Dashboard;
next();
} catch (error: any) {
console.log(
// `withOrgAuth error (${req.headers["authorization"]}):`,
`(warning) clerk auth failed:`,
error?.message || error,
);
// console.log(`(warning) clerk auth failed:`, error?.message || error);
logger.warn(`(warning) withOrgAuth failed:`, error?.message || error);
res.status(401).json({ message: "Unauthorized" });
return;
}
};
export const withAuth = async (req: any, res: any, next: NextFunction) => {
const tokenData = await getTokenData(req, res);
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!tokenData) {
if (!session) {
res.status(401).json({ message: "Unauthorized" });
return;
}
req.userId = tokenData?.user_id;
req.userId = session?.user.id;
next();
};

View File

@@ -1,6 +1,11 @@
import "dotenv/config";
import { Queue } from "bullmq";
import { Redis } from "ioredis";
const BACKUP_REDIS_URL = process.env.REDIS_BACKUP_URL || process.env.REDIS_URL;
const MAIN_REDIS_URL = process.env.REDIS_URL;
export class QueueManager {
private static instance: QueueManager;
private queue: Queue | null = null;
@@ -31,16 +36,7 @@ export class QueueManager {
useBackup: boolean;
keepConnection?: boolean;
}) {
// 1. Connect to redis
if (useBackup && !process.env.REDIS_BACKUP_URL) {
console.warn(`REDIS_BACKUP_URL not set, using main redis`);
useBackup = false;
}
const redisUrl = useBackup
? process.env.REDIS_BACKUP_URL
: process.env.REDIS_URL;
const redisUrl = useBackup ? BACKUP_REDIS_URL : MAIN_REDIS_URL;
const connection = new Redis(redisUrl!, {
retryStrategy: (times) => {
@@ -52,7 +48,7 @@ export class QueueManager {
console.log(
`Redis connection error (${useBackup ? "backup" : "main"}): ${
error.message
}`,
}`
);
if (!keepConnection) {
@@ -95,7 +91,7 @@ export class QueueManager {
console.log("2. Initializing main & backup queues");
const mainQueue = new Queue("autumn", {
connection: {
url: process.env.REDIS_URL,
url: MAIN_REDIS_URL,
enableOfflineQueue: false,
retryStrategy: (times) => {
return 5000;
@@ -105,7 +101,7 @@ export class QueueManager {
const backupQueue = new Queue("autumn", {
connection: {
url: process.env.REDIS_BACKUP_URL,
url: BACKUP_REDIS_URL,
enableOfflineQueue: false,
},
});

Some files were not shown because too many files have changed in this diff Show More