Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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;
"
10 changes: 4 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "voiceConversationCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "voiceConversationFreeLimitOverride" INTEGER;
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
120 changes: 81 additions & 39 deletions sources/app/api/routes/voiceRoutes.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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
Expand All @@ -90,23 +125,30 @@ 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
await db.account.update({
where: { id: userId },
data: { voiceConversationCount: { increment: 1 } }
});

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
});
});
}
}
8 changes: 4 additions & 4 deletions sources/utils/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Expand All @@ -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),
};
}
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down