Add chat-first onboarding and intelligent context-based paywall
Replaces the hardcoded message limit with a model-driven paywall and removes registration before the first question: - Onboarding: the app opens directly into the chat. Dynamic conversation starters are served by GET /suggestions (services/api/suggestions.json), updatable with a deploy — no app release needed. Guests chat via POST /guest/chat, identified by an app-generated device id; sign-in moves to the paywall, where a purchase must attach to an account. - Free experience: the model runs in discovery mode — follow-up questions, pattern identification, visible understanding — building an analysis without delivering the full solution. - Intelligent paywall: the model returns structured output (reply + analysis_ready). Only when the problem is described, the information is sufficient and an action plan is ready does it write a calm transition and pause the conversation. Manufactured urgency, emotional pressure, fake readiness and mid-answer stops are explicitly forbidden. - Premium: on unlock the app resends the transcript and the backend immediately delivers the full analysis, strategies and exercises, then the dialogue continues without restriction. - The old FREE_MESSAGE_LIMIT becomes MESSAGE_CAP (default 200/30 days), kept purely as an abuse backstop — it is not the paywall. - WelcomeScreen removed; the app is now two views (Chat, Paywall). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
+56
-15
@@ -1,10 +1,11 @@
|
||||
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda';
|
||||
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
|
||||
import { generateReply, type ChatMessage } from './chat.js';
|
||||
import { config } from './config.js';
|
||||
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
|
||||
import { verifyAndApplyPurchase } from './subscription/index.js';
|
||||
import type { VerifyPurchaseRequest } from './subscription/types.js';
|
||||
import { canSendMessage, shouldResetUsage } from './usage.js';
|
||||
import suggestionsFile from '../suggestions.json';
|
||||
|
||||
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
|
||||
return {
|
||||
@@ -20,10 +21,17 @@ interface Identity {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity {
|
||||
const claims = event.requestContext.authorizer.jwt.claims;
|
||||
/** Reads the Cognito JWT claims on authorized routes; null on public routes. */
|
||||
function identityFromEvent(event: APIGatewayProxyEventV2): Identity | null {
|
||||
const authorizer = (
|
||||
event.requestContext as {
|
||||
authorizer?: { jwt?: { claims?: Record<string, unknown> } };
|
||||
}
|
||||
).authorizer;
|
||||
const claims = authorizer?.jwt?.claims;
|
||||
if (!claims) return null;
|
||||
const sub = String(claims.sub ?? '');
|
||||
if (!sub) throw new Error('JWT is missing a sub claim');
|
||||
if (!sub) return null;
|
||||
const email = String(claims.email ?? '');
|
||||
// Cognito sets an `identities` claim for federated sign-ins (Apple/Google).
|
||||
const identities = String(claims.identities ?? '');
|
||||
@@ -49,29 +57,43 @@ async function handleMe(user: UserRow): Promise<APIGatewayProxyResultV2> {
|
||||
return json(200, {
|
||||
subscriptionStatus: user.subscription_status,
|
||||
messagesUsed: usage.messages_used,
|
||||
freeMessageLimit: config.freeMessageLimit,
|
||||
messageCap: config.messageCap,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The paywall is intelligent: no hardcoded message count. In free mode the
|
||||
* model works in discovery (follow-up questions, patterns, understanding)
|
||||
* and signals `analysisReady` when a concrete, valuable answer could be
|
||||
* delivered — that is the paywall moment, returned as `paywall: true`.
|
||||
* Premium mode delivers the full analysis, including immediately after
|
||||
* unlock (the transcript then ends with the assistant's transition message).
|
||||
*/
|
||||
async function handleChat(
|
||||
user: UserRow,
|
||||
body: string | undefined,
|
||||
): Promise<APIGatewayProxyResultV2> {
|
||||
const premium = user.subscription_status === 'active';
|
||||
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
|
||||
const messages = parsed.messages ?? [];
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || last.role !== 'user' || typeof last.content !== 'string' || !last.content.trim()) {
|
||||
const validLast =
|
||||
last &&
|
||||
typeof last.content === 'string' &&
|
||||
last.content.trim() &&
|
||||
(last.role === 'user' || (premium && last.role === 'assistant'));
|
||||
if (!validLast) {
|
||||
return json(400, { error: 'messages must end with a non-empty user message' });
|
||||
}
|
||||
|
||||
const usage = await currentUsage(user);
|
||||
if (!canSendMessage(usage.messages_used, user.subscription_status, config.freeMessageLimit)) {
|
||||
return json(402, { error: 'free_limit_reached' });
|
||||
if (!canSendMessage(usage.messages_used, user.subscription_status, config.messageCap)) {
|
||||
return json(402, { error: 'message_cap_reached' });
|
||||
}
|
||||
|
||||
const reply = await generateReply(messages);
|
||||
const result = await generateReply(messages, premium ? 'premium' : 'free');
|
||||
await incrementUsage(user.id);
|
||||
return json(200, { reply });
|
||||
return json(200, { reply: result.reply, paywall: result.analysisReady });
|
||||
}
|
||||
|
||||
async function handleVerifyPurchase(
|
||||
@@ -90,14 +112,33 @@ async function handleVerifyPurchase(
|
||||
return json(200, { subscriptionStatus: status });
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
event: APIGatewayProxyEventV2WithJWTAuthorizer,
|
||||
): Promise<APIGatewayProxyResultV2> {
|
||||
/** Guests chat before signing in, identified by an app-generated device id. */
|
||||
async function handleGuestChat(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
|
||||
const deviceId = event.headers?.['x-device-id'] ?? '';
|
||||
if (!/^[0-9a-fA-F-]{8,64}$/.test(deviceId)) {
|
||||
return json(400, { error: 'a valid X-Device-Id header is required' });
|
||||
}
|
||||
const user = await getOrCreateUser(`guest:${deviceId}`, '', 'guest');
|
||||
return handleChat(user, event.body);
|
||||
}
|
||||
|
||||
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
|
||||
try {
|
||||
const identity = identityFromClaims(event);
|
||||
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
|
||||
const route = `${event.requestContext.http.method} ${event.rawPath}`;
|
||||
|
||||
// Public routes (no sign-in before the first question).
|
||||
if (route === 'GET /suggestions') {
|
||||
return json(200, { suggestions: suggestionsFile.suggestions });
|
||||
}
|
||||
if (route === 'POST /guest/chat') {
|
||||
return await handleGuestChat(event);
|
||||
}
|
||||
|
||||
// Authorized routes (Cognito JWT, enforced by API Gateway).
|
||||
const identity = identityFromEvent(event);
|
||||
if (!identity) return json(401, { error: 'unauthorized' });
|
||||
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
|
||||
|
||||
switch (route) {
|
||||
case 'GET /me':
|
||||
return await handleMe(user);
|
||||
|
||||
Reference in New Issue
Block a user