From 498827c12e27970538513562529f7c3a8358a3ba Mon Sep 17 00:00:00 2001 From: Kirill Dubovitskiy Date: Mon, 22 Dec 2025 04:53:44 -0800 Subject: [PATCH 1/4] feat: add voice conversation tracking for free trial - Introduced new fields in the Account model to track voice conversation count and allow for a free trial limit override. - Updated voice token route to incorporate free trial logic, checking user limits and incrementing conversation counts accordingly. --- package.json | 10 +- .../migration.sql | 3 + prisma/schema.prisma | 5 + sources/app/api/routes/voiceRoutes.ts | 123 ++++++++++++------ sources/utils/log.ts | 8 +- yarn.lock | 8 +- 6 files changed, 104 insertions(+), 53 deletions(-) create mode 100644 prisma/migrations/20251222081854_voice_session_count_for_free_trial/migration.sql diff --git a/package.json b/package.json index a2a4ffc..13ad2a4 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,9 @@ "migrate:reset": "dotenv -e .env.dev -- prisma migrate reset", "generate": "prisma generate", "postinstall": "prisma generate", - "db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres", - "redis": "docker run -d -p 6379:6379 redis", - "s3": "docker run -d --name minio -p 9000:9000 -p 9001:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v $(pwd)/.minio/data:/data minio/minio server /data --console-address :9001", - "s3:down": "docker rm -f minio || true", - "s3:init": "dotenv -e .env.dev -- docker run --rm --network container:minio --entrypoint /bin/sh minio/mc -c \"mc alias set local http://localhost:9000 $S3_ACCESS_KEY $S3_SECRET_KEY && mc mb -p local/$S3_BUCKET || true && mc anonymous set download local/$S3_BUCKET\"" + "db": "docker run -d --name happy-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres", + "redis": "docker run -d --name happy-redis -p 6379:6379 redis", + "s3": "docker run -d --name happy-minio -p 9000:9000 -p 9001:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v $(pwd)/.minio/data:/data minio/minio server /data --console-address :9001" }, "devDependencies": { "@types/chalk": "^2.2.0", @@ -39,7 +37,7 @@ "@prisma/client": "^6.11.1", "@socket.io/redis-streams-adapter": "^0.2.2", "@types/jsonwebtoken": "^9.0.10", - "@types/semver": "^7.7.0", + "@types/semver": "^7.7.1", "axios": "^1.6.8", "chalk": "4.1.2", "date-fns": "^4.1.0", diff --git a/prisma/migrations/20251222081854_voice_session_count_for_free_trial/migration.sql b/prisma/migrations/20251222081854_voice_session_count_for_free_trial/migration.sql new file mode 100644 index 0000000..9f6d451 --- /dev/null +++ b/prisma/migrations/20251222081854_voice_session_count_for_free_trial/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "voiceConversationCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "voiceConversationFreeLimitOverride" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6349305..0ef1c09 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,11 @@ model Account { /// [ImageRef] avatar Json? + // Voice conversation tracking for free trial + voiceConversationCount Int @default(0) + // Set this to expand certain user's free trial limit beyond the default (VOICE_FREE_TRIAL_LIMIT env var) + voiceConversationFreeLimitOverride Int? + Session Session[] AccountPushToken AccountPushToken[] TerminalAuthRequest TerminalAuthRequest[] diff --git a/sources/app/api/routes/voiceRoutes.ts b/sources/app/api/routes/voiceRoutes.ts index 83b12bf..f920933 100644 --- a/sources/app/api/routes/voiceRoutes.ts +++ b/sources/app/api/routes/voiceRoutes.ts @@ -1,20 +1,28 @@ import { z } from "zod"; import { type Fastify } from "../types"; import { log } from "@/utils/log"; +import { db } from "@/storage/db"; + +// RevenueCat v2 GET /customers/{id}/subscriptions responses: +// Success: { items: [{ status: 'active', gives_access: true }] } +// No sub: { items: [] } +// Not found (404): { type: 'resource_missing', message: '...' } + +const DEFAULT_FREE_TRIAL_LIMIT = parseInt(process.env.VOICE_FREE_TRIAL_LIMIT || '3'); export function voiceRoutes(app: Fastify) { app.post('/v1/voice/token', { preHandler: app.authenticate, schema: { body: z.object({ - agentId: z.string(), - revenueCatPublicKey: z.string().optional() + agentId: z.string() }), response: { 200: z.object({ allowed: z.boolean(), token: z.string().optional(), - agentId: z.string().optional() + agentId: z.string().optional(), + freeTrialsRemaining: z.number().optional() }), 400: z.object({ allowed: z.boolean(), @@ -24,52 +32,79 @@ export function voiceRoutes(app: Fastify) { } }, async (request, reply) => { const userId = request.userId; // CUID from JWT - const { agentId, revenueCatPublicKey } = request.body; + const { agentId } = request.body; log({ module: 'voice' }, `Voice token request from user ${userId}`); - const isDevelopment = process.env.NODE_ENV === 'development' || process.env.ENV === 'dev'; + // Get user's current voice conversation count + const account = await db.account.findUnique({ + where: { id: userId }, + select: { voiceConversationCount: true, voiceConversationFreeLimitOverride: true } + }); - // Production requires RevenueCat key - if (!isDevelopment && !revenueCatPublicKey) { - log({ module: 'voice' }, 'Production environment requires RevenueCat public key'); - return reply.code(400).send({ - allowed: false, - error: 'RevenueCat public key required' - }); + if (!account) { + log({ module: 'voice' }, `User ${userId} not found`); + return reply.code(400).send({ allowed: false, error: 'User not found' }); } - // Check subscription in production - if (!isDevelopment && revenueCatPublicKey) { - const response = await fetch( - `https://api.revenuecat.com/v1/subscribers/${userId}`, - { - method: 'GET', - headers: { - 'Authorization': `Bearer ${revenueCatPublicKey}`, - 'Content-Type': 'application/json' - } + const limit = account.voiceConversationFreeLimitOverride ?? DEFAULT_FREE_TRIAL_LIMIT; + const count = account.voiceConversationCount ?? 0; + const hasFreeTrial = count < limit; + + log({ module: 'voice' }, `User ${userId} voice usage: ${count}/${limit}, hasFreeTrial: ${hasFreeTrial}`); + + // If user has free trials, allow without checking subscription + if (hasFreeTrial) { + log({ module: 'voice' }, `User ${userId} has free trial remaining (${count}/${limit})`); + } else if (process.env.VOICE_REQUIRE_SUBSCRIPTION !== 'false') { + // No free trials left, check subscription + const revenueCatSecretKey = process.env.REVENUECAT_API_KEY; + const revenueCatProjectId = process.env.REVENUECAT_PROJECT; + + if (!revenueCatSecretKey || !revenueCatProjectId) { + log({ module: 'voice' }, `Missing RevenueCat config - secretKey: ${!!revenueCatSecretKey}, projectId: ${!!revenueCatProjectId}`); + return reply.code(400).send({ + allowed: false, + error: 'RevenueCat not configured' + }); + } + + const revenueCatUrl = `https://api.revenuecat.com/v2/projects/${revenueCatProjectId}/customers/${userId}/subscriptions`; + log({ module: 'voice' }, `Checking RevenueCat subscription: ${revenueCatUrl}`); + + const revenueCatSubscriptionCheckResponse = await fetch(revenueCatUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${revenueCatSecretKey}`, + 'Content-Type': 'application/json' } - ); + }); + + const responseText = await revenueCatSubscriptionCheckResponse.text(); + log({ module: 'voice' }, `RevenueCat response status: ${revenueCatSubscriptionCheckResponse.status}, body: ${responseText}`); - if (!response.ok) { - log({ module: 'voice' }, `RevenueCat check failed for user ${userId}: ${response.status}`); - return reply.send({ + if (!revenueCatSubscriptionCheckResponse.ok) { + log({ module: 'voice' }, `RevenueCat check failed for user ${userId}: ${revenueCatSubscriptionCheckResponse.status}`); + return reply.send({ allowed: false, agentId }); } - const data = await response.json() as any; - const proEntitlement = data.subscriber?.entitlements?.active?.pro; - - if (!proEntitlement) { - log({ module: 'voice' }, `User ${userId} does not have active subscription`); - return reply.send({ + const revenueCatData = JSON.parse(responseText) as any; + const hasActiveSubscription = revenueCatData.items?.some((sub: any) => sub.status === 'active'); + + if (!hasActiveSubscription) { + log({ module: 'voice' }, `User ${userId} does not have active subscription and no free trials left`); + return reply.send({ allowed: false, agentId }); } + + log({ module: 'voice' }, `User ${userId} has active subscription`); + } else { + log({ module: 'voice' }, `Bypassing subscription check - VOICE_REQUIRE_SUBSCRIPTION=false`); } // Check if 11Labs API key is configured @@ -90,23 +125,33 @@ export function voiceRoutes(app: Fastify) { } } ); - + if (!response.ok) { log({ module: 'voice' }, `Failed to get 11Labs token for user ${userId}`); - return reply.code(400).send({ + return reply.code(400).send({ allowed: false, error: `Failed to get 11Labs token for user ${userId}` }); } - const data = await response.json() as any; - const token = data.token; + const elevenLabsData = await response.json() as any; + const elevenLabsToken = elevenLabsData.token; + + // Increment voice conversation count (only if using free trial) + if (hasFreeTrial) { + await db.account.update({ + where: { id: userId }, + data: { voiceConversationCount: { increment: 1 } } + }); + log({ module: 'voice' }, `Incremented voice count for user ${userId} to ${count + 1}/${limit}`); + } log({ module: 'voice' }, `Voice token issued for user ${userId}`); return reply.send({ allowed: true, - token, - agentId + token: elevenLabsToken, + agentId, + freeTrialsRemaining: hasFreeTrial ? limit - count - 1 : undefined }); }); -} +} \ No newline at end of file diff --git a/sources/utils/log.ts b/sources/utils/log.ts index d07402a..859604e 100644 --- a/sources/utils/log.ts +++ b/sources/utils/log.ts @@ -39,9 +39,9 @@ transports.push({ target: 'pino-pretty', options: { colorize: true, - translateTime: 'HH:MM:ss.l', - ignore: 'pid,hostname', - messageFormat: '{levelLabel} {msg} | [{time}]', + translateTime: 'SYS:HH:MM:ss.l', // SYS: prefix = local time + ignore: 'pid,hostname,module,localTime', + messageFormat: '[{module}] {msg}', errorLikeObjectKeys: ['err', 'error'], }, }); @@ -65,9 +65,9 @@ export const logger = pino({ }, formatters: { log: (object: any) => { - // Add localTime to every log entry return { ...object, + module: object.module || 'server', localTime: formatLocalTime(typeof object.time === 'number' ? object.time : undefined), }; } diff --git a/yarn.lock b/yarn.lock index c9500db..973a409 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1190,10 +1190,10 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/semver@^7.7.0": - version "7.7.0" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e" - integrity sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA== +"@types/semver@^7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" + integrity sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA== "@types/send@*": version "0.17.4" From c4bfb3ead1ce0d0583cab6b8c04c64fc6eb78b06 Mon Sep 17 00:00:00 2001 From: Kirill Dubovitskiy Date: Mon, 22 Dec 2025 04:59:09 -0800 Subject: [PATCH 2/4] fix: always increment voice conversation count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- sources/app/api/routes/voiceRoutes.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/sources/app/api/routes/voiceRoutes.ts b/sources/app/api/routes/voiceRoutes.ts index f920933..e9dbf74 100644 --- a/sources/app/api/routes/voiceRoutes.ts +++ b/sources/app/api/routes/voiceRoutes.ts @@ -137,14 +137,11 @@ export function voiceRoutes(app: Fastify) { const elevenLabsData = await response.json() as any; const elevenLabsToken = elevenLabsData.token; - // Increment voice conversation count (only if using free trial) - if (hasFreeTrial) { - await db.account.update({ - where: { id: userId }, - data: { voiceConversationCount: { increment: 1 } } - }); - log({ module: 'voice' }, `Incremented voice count for user ${userId} to ${count + 1}/${limit}`); - } + // Increment voice conversation count + await db.account.update({ + where: { id: userId }, + data: { voiceConversationCount: { increment: 1 } } + }); log({ module: 'voice' }, `Voice token issued for user ${userId}`); return reply.send({ From 63488d4789490cf1967ba5b694832a3a89989697 Mon Sep 17 00:00:00 2001 From: Kirill Dubovitskiy Date: Wed, 24 Dec 2025 10:29:54 -0800 Subject: [PATCH 3/4] Include prisma schema in runtime image Fix: Copy prisma/ folder to runtime stage so db migrations can be run from within the container. Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 2e620e8..ce150a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ COPY --from=builder /app/tsconfig.json ./tsconfig.json COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/sources ./sources +COPY --from=builder /app/prisma ./prisma # Expose the port the app will run on EXPOSE 3000 From bf21e49b30d79e6dff655931774a5ea13c2c3c1b Mon Sep 17 00:00:00 2001 From: Kirill Dubovitskiy Date: Thu, 25 Dec 2025 12:24:20 -0800 Subject: [PATCH 4/4] Add docker-compose for local development dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces minikube-based local setup with simple docker-compose: - PostgreSQL 17 (matches production schema compatibility) - Redis 7 for caching/pubsub - MinIO for S3-compatible object storage Usage: docker compose up -d && yarn dev 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker-compose.yaml | 65 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..be86268 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,65 @@ +# Local development dependencies +# Usage: docker compose up -d +# Then: yarn dev +version: '3.8' + +services: + postgres: + image: postgres:17-alpine + container_name: happy-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: happy + POSTGRES_PASSWORD: localdevpassword + POSTGRES_DB: happy + volumes: + - ./.pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U happy"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: happy-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + container_name: happy-minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: happyaccesskey + MINIO_ROOT_PASSWORD: happysecretkey123 + volumes: + - ./.minio:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + + # Create the default bucket on startup + minio-setup: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 happyaccesskey happysecretkey123; + mc mb myminio/happy-files --ignore-existing; + mc anonymous set download myminio/happy-files; + exit 0; + "