Bygg NeuroSemantics AI: minimal mobilapp, en backend, IaC och CI/CD

Ersätter den tidigare webappen på denna branch med ett fokuserat monorepo:

- apps/mobile: Expo/React Native med tre vyer (Welcome, Chat, Paywall),
  Cognito hosted UI-inloggning (Apple/Google/e-post) och In-App
  Purchase/Play Billing via en gemensam purchases-modul.
- services/api: en enda Lambda-backend — OpenAI Responses API med
  Markdown-kunskapsbas som systeminstruktioner, free tier-gräns i
  PostgreSQL (HTTP 402 -> paywall) och kvittoverifiering bakom ett
  delat PaymentProvider-interface (Apple/Google, Stripe kan läggas
  till för webb senare).
- infra: AWS CDK-stack med API Gateway (JWT-authorizer), Lambda,
  Cognito, Aurora Serverless v2 och Secrets Manager.
- db/migrations: minimal datamodell (users + usage), inga
  konversationer sparas.
- GitHub Actions: CI (lint, typecheck, test) och deploy från main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
Claude
2026-08-03 14:35:48 +00:00
parent 508cb534b0
commit 07fbeea09b
199 changed files with 13626 additions and 18001 deletions
+147
View File
@@ -0,0 +1,147 @@
import { useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { ApiError, sendChat, type ChatMessage } from '../api/client';
import { colors, spacing, type } from '../theme';
interface Props {
onLimitReached: () => void;
}
/**
* The whole product: one heading, the conversation, a text field and send.
* Conversations live in memory only — nothing is stored.
*/
export function ChatScreen({ onLimitReached }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null);
async function send() {
const content = draft.trim();
if (!content || busy) return;
const next: ChatMessage[] = [...messages, { role: 'user', content }];
setMessages(next);
setDraft('');
setBusy(true);
setError(null);
try {
const { reply } = await sendChat(next);
setMessages([...next, { role: 'assistant', content: reply }]);
} catch (e) {
if (e instanceof ApiError && e.status === 402) {
onLimitReached();
} else {
setError('Something went wrong. Please try again.');
setMessages(messages);
setDraft(content);
}
} finally {
setBusy(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.heading}>NeuroSemantics AI</Text>
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={styles.listContent}
data={messages}
keyExtractor={(_, index) => String(index)}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
ListEmptyComponent={<Text style={styles.empty}>What would you like to explore today?</Text>}
renderItem={({ item }) => (
<View style={[styles.message, item.role === 'user' ? styles.user : styles.assistant]}>
<Text style={item.role === 'user' ? styles.userText : styles.assistantText}>
{item.content}
</Text>
</View>
)}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={draft}
onChangeText={setDraft}
placeholder="Write a message"
placeholderTextColor={colors.textMuted}
multiline
editable={!busy}
/>
<Pressable
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
onPress={send}
disabled={!draft.trim() || busy}
>
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
heading: {
...type.heading,
textAlign: 'center',
paddingTop: spacing.xxl,
paddingBottom: spacing.m,
},
list: { flex: 1 },
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl },
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
assistant: {
alignSelf: 'flex-start',
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
},
userText: { ...type.body, color: colors.accentText },
assistantText: { ...type.body },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginBottom: spacing.s },
inputRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.s,
paddingHorizontal: spacing.l,
paddingBottom: spacing.xl,
},
input: {
flex: 1,
...type.body,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s + 2,
maxHeight: 120,
},
send: {
backgroundColor: colors.accent,
borderRadius: 12,
paddingHorizontal: spacing.l,
paddingVertical: spacing.m - 2,
},
sendDisabled: { opacity: 0.4 },
sendText: { ...type.heading, color: colors.accentText },
});
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { purchase, restore, type Plan } from '../purchases';
import { colors, spacing, type } from '../theme';
interface Props {
onSubscribed: () => void;
}
export function PaywallScreen({ onSubscribed }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(action: () => Promise<'free' | 'active'>) {
setBusy(true);
setError(null);
try {
const status = await action();
if (status === 'active') {
onSubscribed();
} else {
setError('No active subscription was found.');
}
} catch {
setError('The purchase did not complete. Please try again.');
} finally {
setBusy(false);
}
}
const buy = (plan: Plan) => run(() => purchase(plan));
return (
<View style={styles.container}>
<View style={styles.top}>
<Text style={styles.title}>Continue your journey</Text>
<Text style={styles.subtitle}>
You have used your free conversations. Subscribe to continue.
</Text>
</View>
<View style={styles.actions}>
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Monthly</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Yearly</Text>
</Pressable>
<Pressable style={styles.restore} onPress={() => run(restore)} disabled={busy}>
<Text style={styles.restoreText}>Restore Purchase</Text>
</Pressable>
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: spacing.l,
justifyContent: 'space-between',
},
top: { marginTop: spacing.xxl * 2 },
title: { ...type.title, textAlign: 'center' },
subtitle: {
...type.body,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.m,
},
actions: { marginBottom: spacing.xxl, gap: spacing.s },
primaryButton: {
backgroundColor: colors.accent,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
restore: { alignItems: 'center', paddingVertical: spacing.m },
restoreText: { ...type.body, color: colors.accent },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
});
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { signIn, type Provider } from '../auth/session';
import { colors, spacing, type } from '../theme';
interface Props {
onSignedIn: () => void;
}
export function WelcomeScreen({ onSignedIn }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSignIn(provider: Provider) {
setBusy(true);
setError(null);
try {
await signIn(provider);
onSignedIn();
} catch {
setError('Sign-in did not complete. Please try again.');
} finally {
setBusy(false);
}
}
return (
<View style={styles.container}>
<View style={styles.top}>
<Text style={styles.title}>NeuroSemantics AI</Text>
<Text style={styles.subtitle}>
A quiet conversation partner for neurosemantics and NLP.
</Text>
</View>
<View style={styles.actions}>
<Pressable
style={styles.primaryButton}
onPress={() => handleSignIn('apple')}
disabled={busy}
>
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('google')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('email')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
</Pressable>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Text style={styles.footnote}>Start free. No card required.</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: spacing.l,
justifyContent: 'space-between',
},
top: { marginTop: spacing.xxl * 2 },
title: { ...type.title, textAlign: 'center' },
subtitle: {
...type.body,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.m,
},
actions: { marginBottom: spacing.xxl, gap: spacing.s },
primaryButton: {
backgroundColor: colors.accent,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
secondaryButton: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
secondaryButtonText: { ...type.heading },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginTop: spacing.s },
footnote: { ...type.caption, textAlign: 'center', marginTop: spacing.m },
});