Store readiness: in-app account deletion, privacy policy, terms, listing copy
Closes the remaining store-compliance gaps before App Store / Play submission: - Account deletion (App Store guideline 5.1.1): DELETE /me removes the user row (usage cascades). The app exposes it through one quiet 'Account' caption link under the chat, visible only when signed in, driving two native dialogs (Sign out / Delete account with a destructive confirm) — no new views, no menus, minimalism intact. Deletion signs out and resets the app; copy notes that store subscriptions are cancelled in App Store / Play settings. - docs/store/privacy-policy.md: the complete data inventory (matching the actual schema), transient OpenAI processing with no training, no profiling or ads, GDPR legal bases and rights, in-app erasure. - docs/store/terms-of-service.md: not-therapy positioning with crisis guidance, AI-generated-content caveat, 18+ eligibility, auto-renewal/cancellation terms, liability, Swedish governing law. - docs/store/listing.md: App Store and Play copy written to the honest-claims rule (subtitle 'Think Beyond Thought', keywords, descriptions), plus App Privacy and Data safety questionnaire mappings. - LAUNCH.md updated: host the policy/terms, set store URLs, use the prepared listing copy. - Tests: 30 passing (adds DELETE /me coverage). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
@@ -46,8 +46,15 @@ the README).
|
|||||||
two subscriptions with the same ids and prices. Create a service
|
two subscriptions with the same ids and prices. Create a service
|
||||||
account with Play Developer API access → its JSON key becomes
|
account with Play Developer API access → its JSON key becomes
|
||||||
`GOOGLE_SERVICE_ACCOUNT_JSON`.
|
`GOOGLE_SERVICE_ACCOUNT_JSON`.
|
||||||
- [ ] Store copy follows the honest-claims rule: inspiration, not proven
|
- [ ] Store copy: use `docs/store/listing.md` (already written to the
|
||||||
effects. Positioning: reflection partner — not therapy.
|
honest-claims rule — inspiration, not proven effects; reflection
|
||||||
|
partner, not therapy).
|
||||||
|
- [ ] Fill the [PLACEHOLDERS] in `docs/store/privacy-policy.md` and
|
||||||
|
`docs/store/terms-of-service.md`, host them on a public URL (GitHub
|
||||||
|
Pages is enough), and set the URLs in both store consoles.
|
||||||
|
- [ ] App Privacy / Data safety forms: mappings are in
|
||||||
|
`docs/store/listing.md`. In-app account deletion (App Store 5.1.1)
|
||||||
|
is implemented: Account → Delete account.
|
||||||
|
|
||||||
## 5. Builds
|
## 5. Builds
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import type { ChatMessage } from './src/api/client';
|
import type { ChatMessage } from './src/api/client';
|
||||||
|
import { getAccessToken } from './src/auth/session';
|
||||||
import { ChatScreen } from './src/screens/ChatScreen';
|
import { ChatScreen } from './src/screens/ChatScreen';
|
||||||
import { PaywallScreen } from './src/screens/PaywallScreen';
|
import { PaywallScreen } from './src/screens/PaywallScreen';
|
||||||
|
|
||||||
@@ -15,6 +16,19 @@ export default function App() {
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [paywalled, setPaywalled] = useState(false);
|
const [paywalled, setPaywalled] = useState(false);
|
||||||
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
|
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
|
||||||
|
const [signedIn, setSignedIn] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAccessToken().then((token) => setSignedIn(token !== null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function resetToSignedOut() {
|
||||||
|
setSignedIn(false);
|
||||||
|
setMessages([]);
|
||||||
|
setPaywalled(false);
|
||||||
|
setDeliverAnalysis(false);
|
||||||
|
setView('chat');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -28,11 +42,14 @@ export default function App() {
|
|||||||
onContinueWithPremium={() => setView('paywall')}
|
onContinueWithPremium={() => setView('paywall')}
|
||||||
deliverAnalysis={deliverAnalysis}
|
deliverAnalysis={deliverAnalysis}
|
||||||
onAnalysisDelivered={() => setDeliverAnalysis(false)}
|
onAnalysisDelivered={() => setDeliverAnalysis(false)}
|
||||||
|
signedIn={signedIn}
|
||||||
|
onSignedOut={resetToSignedOut}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'paywall' && (
|
{view === 'paywall' && (
|
||||||
<PaywallScreen
|
<PaywallScreen
|
||||||
onSubscribed={() => {
|
onSubscribed={() => {
|
||||||
|
setSignedIn(true);
|
||||||
setPaywalled(false);
|
setPaywalled(false);
|
||||||
setDeliverAnalysis(true);
|
setDeliverAnalysis(true);
|
||||||
setView('chat');
|
setView('chat');
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ export async function fetchMe(): Promise<Me> {
|
|||||||
return request<Me>('/me', {}, await authHeader());
|
return request<Me>('/me', {}, await authHeader());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Permanently deletes the account and its usage data (App Store 5.1.1). */
|
||||||
|
export async function deleteAccount(): Promise<void> {
|
||||||
|
await request<{ deleted: boolean }>('/me', { method: 'DELETE' }, await authHeader());
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyPurchase(body: {
|
export async function verifyPurchase(body: {
|
||||||
platform: 'ios' | 'android';
|
platform: 'ios' | 'android';
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -9,7 +10,8 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
|
import { deleteAccount, fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
|
||||||
|
import { signOut } from '../auth/session';
|
||||||
import { colors, spacing, type } from '../theme';
|
import { colors, spacing, type } from '../theme';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,6 +24,9 @@ interface Props {
|
|||||||
/** Set right after unlock: fetch the promised analysis automatically. */
|
/** Set right after unlock: fetch the promised analysis automatically. */
|
||||||
deliverAnalysis: boolean;
|
deliverAnalysis: boolean;
|
||||||
onAnalysisDelivered: () => void;
|
onAnalysisDelivered: () => void;
|
||||||
|
/** Shows the quiet Account affordance (sign out / delete account). */
|
||||||
|
signedIn: boolean;
|
||||||
|
onSignedOut: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUGGESTIONS_SHOWN = 4;
|
const SUGGESTIONS_SHOWN = 4;
|
||||||
@@ -39,6 +44,8 @@ export function ChatScreen({
|
|||||||
onContinueWithPremium,
|
onContinueWithPremium,
|
||||||
deliverAnalysis,
|
deliverAnalysis,
|
||||||
onAnalysisDelivered,
|
onAnalysisDelivered,
|
||||||
|
signedIn,
|
||||||
|
onSignedOut,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
@@ -92,6 +99,42 @@ export function ChatScreen({
|
|||||||
|
|
||||||
const showSuggestions = messages.length === 0 && suggestions.length > 0;
|
const showSuggestions = messages.length === 0 && suggestions.length > 0;
|
||||||
|
|
||||||
|
// No menus, no settings view: account management lives in two native
|
||||||
|
// dialogs behind one quiet link (in-app deletion per App Store 5.1.1).
|
||||||
|
function openAccount() {
|
||||||
|
Alert.alert('Account', undefined, [
|
||||||
|
{
|
||||||
|
text: 'Sign out',
|
||||||
|
onPress: () => {
|
||||||
|
signOut().then(onSignedOut);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete account',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () =>
|
||||||
|
Alert.alert(
|
||||||
|
'Delete account?',
|
||||||
|
'This permanently deletes your account and usage data. Conversations are never stored. An active subscription is cancelled in your App Store or Google Play settings.',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
deleteAccount()
|
||||||
|
.then(() => signOut())
|
||||||
|
.then(onSignedOut)
|
||||||
|
.catch(() => setError('Something went wrong. Please try again.'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
@@ -150,6 +193,11 @@ export function ChatScreen({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{signedIn ? (
|
||||||
|
<Pressable style={styles.accountLink} onPress={openAccount}>
|
||||||
|
<Text style={styles.accountText}>Account</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,8 +239,10 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
gap: spacing.s,
|
gap: spacing.s,
|
||||||
paddingHorizontal: spacing.l,
|
paddingHorizontal: spacing.l,
|
||||||
paddingBottom: spacing.xl,
|
paddingBottom: spacing.m,
|
||||||
},
|
},
|
||||||
|
accountLink: { alignItems: 'center', paddingBottom: spacing.l },
|
||||||
|
accountText: { ...type.caption },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
...type.body,
|
...type.body,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Store listing copy
|
||||||
|
|
||||||
|
All copy follows the honest-claims rule: neurosemantics/NLP as inspiration
|
||||||
|
and models for reflection — never "scientifically proven", never promised
|
||||||
|
results. Tone: calm, clear, no hype.
|
||||||
|
|
||||||
|
## App Store (iOS)
|
||||||
|
|
||||||
|
- **Name:** Semantika
|
||||||
|
- **Subtitle** (≤30 chars): `Think Beyond Thought`
|
||||||
|
- **Category:** Lifestyle (primary), Education (secondary)
|
||||||
|
- **Age rating:** 17+ (unrestricted web-like AI content; matches 18+ terms)
|
||||||
|
- **Keywords** (≤100 chars):
|
||||||
|
`reflection,thinking,clarity,communication,meaning,mindset,self insight,NLP,decision,conversation`
|
||||||
|
- **Promotional text:**
|
||||||
|
A quiet conversation partner for your thinking. Explore how you create
|
||||||
|
meaning — one structured conversation at a time.
|
||||||
|
- **Description:**
|
||||||
|
|
||||||
|
Semantika is a minimalist AI conversation partner for reflection,
|
||||||
|
inspired by neurosemantic principles.
|
||||||
|
|
||||||
|
Start a conversation about what is on your mind — a difficult
|
||||||
|
conversation ahead, a decision you keep postponing, a pattern you keep
|
||||||
|
repeating. Semantika listens, asks considered follow-up questions, and
|
||||||
|
helps you see how you interpret your situation. When the picture is
|
||||||
|
clear, it offers a structured analysis, concrete recommendations, and
|
||||||
|
practical exercises.
|
||||||
|
|
||||||
|
No feeds. No streaks. No noise. One conversation at a time.
|
||||||
|
|
||||||
|
— Begin immediately: no registration before your first question
|
||||||
|
— Conversations are never stored, and never used for training
|
||||||
|
— A calm, structured method: acknowledge, explore, lift, challenge
|
||||||
|
— Premium: full analyses, personalised action plans, unlimited dialogue
|
||||||
|
|
||||||
|
Semantika is not therapy and does not replace professional care. Its
|
||||||
|
models are offered as perspectives for reflection, inspired by
|
||||||
|
neurosemantics and NLP.
|
||||||
|
|
||||||
|
Subscriptions ($5.99/month or $49.99/year) renew automatically and can
|
||||||
|
be cancelled anytime in your App Store settings.
|
||||||
|
Privacy policy and terms: [PRIVACY_POLICY_URL] · [TERMS_URL]
|
||||||
|
|
||||||
|
## Google Play (Android)
|
||||||
|
|
||||||
|
- **App name:** Semantika
|
||||||
|
- **Short description** (≤80 chars):
|
||||||
|
`A quiet AI conversation partner for reflection, clarity and better decisions.`
|
||||||
|
- **Full description:** reuse the App Store description above.
|
||||||
|
- **Category:** Lifestyle
|
||||||
|
|
||||||
|
## App Privacy (Apple questionnaire mapping)
|
||||||
|
|
||||||
|
- **Contact info → Email address:** collected, linked to identity, for app
|
||||||
|
functionality only. No tracking.
|
||||||
|
- **Identifiers → User ID:** account id / device id, app functionality only.
|
||||||
|
- **Purchases:** subscription status, app functionality only.
|
||||||
|
- **User content (chat):** processed but **not collected/stored**; declare
|
||||||
|
as not collected (transient processing), and describe in the privacy
|
||||||
|
policy.
|
||||||
|
- **Tracking:** none. No ads, no data sold or shared for advertising.
|
||||||
|
|
||||||
|
## Data safety (Google Play form)
|
||||||
|
|
||||||
|
- Collected: email, user ids, purchase history (subscription status).
|
||||||
|
- Shared: none. Encrypted in transit: yes. Deletable: yes, in-app.
|
||||||
|
- Chat content: processed ephemerally, not stored.
|
||||||
|
|
||||||
|
## Required URLs
|
||||||
|
|
||||||
|
Host `privacy-policy.md` and `terms-of-service.md` on a public URL (GitHub
|
||||||
|
Pages is enough for the beta) and set them in both store consoles. Replace
|
||||||
|
the [PLACEHOLDERS] in both documents first.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Semantika — Privacy Policy
|
||||||
|
|
||||||
|
_Last updated: [DATE]. Controller: [COMPANY NAME, ADDRESS, ORG NUMBER].
|
||||||
|
Contact: [support@semantika.app]._
|
||||||
|
|
||||||
|
Semantika is built to know as little about you as possible. This policy
|
||||||
|
describes exactly what we process, why, and what we never do.
|
||||||
|
|
||||||
|
## What we store
|
||||||
|
|
||||||
|
| Data | Purpose | Kept until |
|
||||||
|
| ----------------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
|
||||||
|
| Email address and sign-in provider (Apple, Google or email) | Your account | Account deletion |
|
||||||
|
| Subscription status (`free`/`active`) | Unlocking Premium | Account deletion |
|
||||||
|
| Message counters and their reset date | Free-tier limits and abuse prevention | Account deletion |
|
||||||
|
| A random device identifier (guests only) | Letting you chat before creating an account | Removed when you delete the app's data |
|
||||||
|
|
||||||
|
That is the complete list. We do not collect names, birthdays, locations,
|
||||||
|
contacts, photos, advertising identifiers, or analytics profiles.
|
||||||
|
|
||||||
|
## Your conversations
|
||||||
|
|
||||||
|
- Conversations are **never stored on our servers**. They live in your
|
||||||
|
app's memory and disappear when you close the conversation.
|
||||||
|
- To generate each reply, the current conversation is transmitted over
|
||||||
|
encrypted connections to our AI provider, **OpenAI** (acting as a data
|
||||||
|
processor), and processed transiently. Under OpenAI's API terms, API data
|
||||||
|
is **not used to train their models**.
|
||||||
|
- We never use your conversations for advertising, profiling, or training
|
||||||
|
of any kind.
|
||||||
|
- Error logs never contain message content.
|
||||||
|
|
||||||
|
## Payments
|
||||||
|
|
||||||
|
Purchases are handled entirely by Apple (App Store) or Google (Google
|
||||||
|
Play). We never see your card details — only a receipt that we verify with
|
||||||
|
the store and your resulting subscription status.
|
||||||
|
|
||||||
|
## Legal bases (GDPR)
|
||||||
|
|
||||||
|
- **Performance of contract** — providing the service you signed up for.
|
||||||
|
- **Legitimate interest** — preventing abuse of the free tier.
|
||||||
|
|
||||||
|
## Your rights
|
||||||
|
|
||||||
|
You can access, correct, export, or erase your data at any time. Deleting
|
||||||
|
your account (**Account → Delete account** in the app) permanently removes
|
||||||
|
everything in the table above. Cancel an active subscription in your App
|
||||||
|
Store or Google Play settings — deleting the account does not cancel the
|
||||||
|
store subscription. For anything else, contact [support@semantika.app].
|
||||||
|
You may also lodge a complaint with your supervisory authority (in Sweden:
|
||||||
|
IMY).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
All traffic is encrypted (HTTPS). Authentication uses AWS Cognito.
|
||||||
|
Databases are encrypted at rest and unreachable from the public internet.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
If this policy changes materially, we will note it in the app before the
|
||||||
|
change takes effect.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Semantika — Terms of Service
|
||||||
|
|
||||||
|
_Last updated: [DATE]. Provider: [COMPANY NAME, ADDRESS, ORG NUMBER].
|
||||||
|
Contact: [support@semantika.app]._
|
||||||
|
|
||||||
|
## What Semantika is — and is not
|
||||||
|
|
||||||
|
Semantika is an AI conversation partner for reflection, inspired by
|
||||||
|
neurosemantic and NLP models. It helps you explore how you create meaning,
|
||||||
|
interpret your experiences, and communicate.
|
||||||
|
|
||||||
|
Semantika is **not** therapy, medical or psychological care, or
|
||||||
|
professional advice of any kind. Its models are offered as perspectives for
|
||||||
|
reflection — not as scientifically proven methods — and its replies are
|
||||||
|
generated by an AI system and may be incorrect. You remain responsible for
|
||||||
|
your decisions.
|
||||||
|
|
||||||
|
**If you are in crisis or experiencing acute distress, do not use the app —
|
||||||
|
contact your local emergency number or a health professional immediately.**
|
||||||
|
|
||||||
|
## Eligibility
|
||||||
|
|
||||||
|
You must be at least 18 years old to use Semantika.
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
|
||||||
|
- Plans: Monthly and Yearly, purchased through Apple App Store or Google
|
||||||
|
Play. Prices are shown in the app before purchase.
|
||||||
|
- Subscriptions renew automatically until cancelled in your App Store or
|
||||||
|
Google Play settings, at least 24 hours before the current period ends.
|
||||||
|
- Deleting your account does not cancel a store subscription — cancel it in
|
||||||
|
the store first.
|
||||||
|
- Refunds are handled by Apple and Google under their policies.
|
||||||
|
|
||||||
|
## Acceptable use
|
||||||
|
|
||||||
|
Do not use the service unlawfully, attempt to disrupt or reverse-engineer
|
||||||
|
it, or circumvent usage limits. We may suspend accounts that do.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
How we handle data is described in the [Privacy Policy](privacy-policy.md).
|
||||||
|
In short: conversations are never stored, and we keep the absolute minimum.
|
||||||
|
|
||||||
|
## Liability
|
||||||
|
|
||||||
|
To the extent permitted by law, Semantika is provided "as is" and our
|
||||||
|
liability is limited to the amount you paid for the service in the twelve
|
||||||
|
months preceding the claim. Nothing in these terms limits liability that
|
||||||
|
cannot be limited under applicable law.
|
||||||
|
|
||||||
|
## Changes and termination
|
||||||
|
|
||||||
|
We may update these terms; material changes will be announced in the app.
|
||||||
|
You can stop using the service and delete your account at any time
|
||||||
|
(**Account → Delete account**).
|
||||||
|
|
||||||
|
## Governing law
|
||||||
|
|
||||||
|
These terms are governed by the laws of Sweden, and disputes are resolved
|
||||||
|
by Swedish courts, without limiting mandatory consumer protections in your
|
||||||
|
country of residence.
|
||||||
@@ -156,7 +156,7 @@ export class SemantikaStack extends Stack {
|
|||||||
);
|
);
|
||||||
const api = new HttpApi(this, 'HttpApi', { defaultAuthorizer: authorizer });
|
const api = new HttpApi(this, 'HttpApi', { defaultAuthorizer: authorizer });
|
||||||
const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction);
|
const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction);
|
||||||
api.addRoutes({ path: '/me', methods: [HttpMethod.GET], integration });
|
api.addRoutes({ path: '/me', methods: [HttpMethod.GET, HttpMethod.DELETE], integration });
|
||||||
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
|
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
|
||||||
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
|
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export async function incrementUsage(userId: string): Promise<void> {
|
|||||||
await db.query(`update usage set messages_used = messages_used + 1 where user_id = $1`, [userId]);
|
await db.query(`update usage set messages_used = messages_used + 1 where user_id = $1`, [userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Permanently removes the user; the usage row cascades. */
|
||||||
|
export async function deleteUser(userId: string): Promise<void> {
|
||||||
|
const db = await getPool();
|
||||||
|
await db.query(`delete from users where id = $1`, [userId]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setSubscriptionStatus(
|
export async function setSubscriptionStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
status: 'free' | 'active',
|
status: 'free' | 'active',
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
|
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
|
||||||
import { generateReply, type ChatMessage } from './chat.js';
|
import { generateReply, type ChatMessage } from './chat.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
|
import {
|
||||||
|
deleteUser,
|
||||||
|
getOrCreateUser,
|
||||||
|
getUsage,
|
||||||
|
incrementUsage,
|
||||||
|
resetUsage,
|
||||||
|
type UserRow,
|
||||||
|
} from './db.js';
|
||||||
import { verifyAndApplyPurchase } from './subscription/index.js';
|
import { verifyAndApplyPurchase } from './subscription/index.js';
|
||||||
import type { VerifyPurchaseRequest } from './subscription/types.js';
|
import type { VerifyPurchaseRequest } from './subscription/types.js';
|
||||||
import { canSendMessage, shouldResetUsage } from './usage.js';
|
import { canSendMessage, shouldResetUsage } from './usage.js';
|
||||||
@@ -142,6 +149,12 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<APIGateway
|
|||||||
switch (route) {
|
switch (route) {
|
||||||
case 'GET /me':
|
case 'GET /me':
|
||||||
return await handleMe(user);
|
return await handleMe(user);
|
||||||
|
case 'DELETE /me':
|
||||||
|
// In-app account deletion (App Store guideline 5.1.1). Removes the
|
||||||
|
// user and usage rows; store subscriptions are cancelled by the user
|
||||||
|
// in App Store / Play settings.
|
||||||
|
await deleteUser(user.id);
|
||||||
|
return json(200, { deleted: true });
|
||||||
case 'POST /chat':
|
case 'POST /chat':
|
||||||
return await handleChat(user, event.body);
|
return await handleChat(user, event.body);
|
||||||
case 'POST /subscription/verify':
|
case 'POST /subscription/verify':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../src/db.js', () => ({
|
vi.mock('../src/db.js', () => ({
|
||||||
|
deleteUser: vi.fn(),
|
||||||
getOrCreateUser: vi.fn(),
|
getOrCreateUser: vi.fn(),
|
||||||
getUsage: vi.fn(),
|
getUsage: vi.fn(),
|
||||||
incrementUsage: vi.fn(),
|
incrementUsage: vi.fn(),
|
||||||
@@ -14,7 +15,7 @@ vi.mock('../src/subscription/index.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { generateReply } from '../src/chat.js';
|
import { generateReply } from '../src/chat.js';
|
||||||
import { getOrCreateUser, getUsage, incrementUsage } from '../src/db.js';
|
import { deleteUser, getOrCreateUser, getUsage, incrementUsage } from '../src/db.js';
|
||||||
import { handler } from '../src/handler.js';
|
import { handler } from '../src/handler.js';
|
||||||
import { verifyAndApplyPurchase } from '../src/subscription/index.js';
|
import { verifyAndApplyPurchase } from '../src/subscription/index.js';
|
||||||
|
|
||||||
@@ -101,6 +102,15 @@ describe('authorization', () => {
|
|||||||
});
|
});
|
||||||
expect(getOrCreateUser).toHaveBeenCalledWith('sub-1', 'a@b.se', 'apple');
|
expect(getOrCreateUser).toHaveBeenCalledWith('sub-1', 'a@b.se', 'apple');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deletes the account on DELETE /me', async () => {
|
||||||
|
const { status, body } = await call('DELETE', '/me', {
|
||||||
|
claims: { sub: 'sub-1', email: 'a@b.se' },
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.deleted).toBe(true);
|
||||||
|
expect(deleteUser).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chat and the intelligent paywall', () => {
|
describe('chat and the intelligent paywall', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user