Compare commits

Author SHA1 Message Date
jjbrunton 3fc6f8aa1a chore(ios): record v1.0.7 build 46 2026-08-31 01:20:45 +01:00
jjbrunton 02a7349899 chore(android): record v1.0.7 build 49 2026-08-31 00:37:41 +01:00
jjbrunton 8f70dbcc00 fix(mobile): include OTA certificate in builds 2026-08-31 00:16:39 +01:00
jjbrunton ac80d6b614 chore(release): prepare v1.0.7 2026-08-31 00:02:15 +01:00
jjbrunton 056bb62d04 Merge pull request #20 from jjbrunton/staging
release: promote v1.0.7
2026-08-30 23:52:58 +01:00
jjbrunton 80ec1663f9 merge: fix android release native inputs 2026-08-30 23:48:17 +01:00
jjbrunton 8ff05c050b fix(mobile): order android release native inputs
Materialize the Worklets CMake input expected by Reanimated and order\nPrefab producers before the app release CMake configuration.
2026-08-30 23:47:05 +01:00
jjbrunton 549419892d fix(ota): propagate profile env to EOAS 2026-08-30 22:07:38 +01:00
jjbrunton a46b8b95e6 Merge commit '267f3c3cf9e5acf6087a51bc4f81141f8285fb76' into staging 2026-08-30 21:42:44 +01:00
jjbrunton e54f6d31ed fix(admin): forward local Auth settings 2026-08-30 21:40:09 +01:00
jjbrunton 267f3c3cf9 fix(ota): use explicit Expo runtime version 2026-08-30 21:37:28 +01:00
jjbrunton 02aa6e4a75 security(web): upgrade Next to 15.5.24 2026-08-30 21:14:33 +01:00
jjbrunton cf19d23d8a fix(e2e): align browser harness with local stack 2026-08-30 21:00:12 +01:00
jjbrunton db5980c9bd fix(mobile): prepare v1.0.7 OTA release 2026-08-30 20:41:08 +01:00
jjbruntonandClaude Fable 5 2664791233 Merge self-hosted XPREM OTA updates wiring into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:19:07 +01:00
jjbruntonandClaude Fable 5 f37673cad7 feat(mobile): finalize XPREM OTA config with real app id and certificate
Server registration is complete: app id 9a66f3c2-fda7-416f-87e2-6db833b32e9d,
real code-signing certificate (public half), staging/production channels
mapped server-side. Drops the app-id placeholder guard (cert guard stays) and
re-includes certificate.pem past the root *.pem ignore rule, which had been
silently excluding it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:16:12 +01:00
jjbruntonandClaude Fable 5 0ada812294 docs(mobile): document the XPREM over-the-air update flow
Add docs/mobile-ota.md covering how OTA works here, the branch and channel
model, the appVersion runtime version policy and its store-build rule, the
eoas publish commands per channel, both rollback paths, the pending native
prebuild, and the exact placeholders the server deployment still has to fill.

Index it and cross-link it from the release guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:51 +01:00
jjbruntonandClaude Fable 5 f4db567267 feat(mobile): wire expo-updates to the self-hosted XPREM OTA server
Install expo-updates and the eoas CLI, and configure the client against
https://ota.apps.jbrunton.co.uk.

app.json stays the static source of truth for version, buildNumber, and
versionCode, which EAS rewrites during a release build. A new app.config.js
spreads it and layers on the updates block, because XPREM selects a release
channel from a build-time request header and that value differs per EAS
build profile.

- updates.url points at the XPREM manifest endpoint, /manifest
- runtimeVersion uses the appVersion policy, so the runtime version is the
  store-visible expo.version and a native change requires a store build
- expo-channel-name comes from RELEASE_CHANNEL, set per EAS build profile:
  development, staging (preview profile), and production
- expo-app-id and an empty xprem-branch header are declared at build time,
  since expo-updates only sends headers that existed when the build was made
- code signing uses keyid main and rsa-v1_5-sha256 against a certificate at
  certs/certificate.pem, skipped when DISABLE_CODE_SIGNING is set so a dev
  client can run against the packager

The XPREM app id and the code-signing certificate are still placeholders
pending the update server deployment. The config throws a descriptive error
whenever RELEASE_CHANNEL is set while either is unresolved, so a
misconfigured build fails at config resolution rather than shipping a client
that can never validate a manifest.

The native projects are Expo CNG output and are left untouched: the
expo-updates config plugin embeds the real app id and certificate contents,
so they are regenerated as a reviewed source change once both values land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:51 +01:00
jjbruntonandClaude Fable 5 d298a33656 feat(admin): proxy /v1 API calls through the local admin dev server
The API has no CORS handling, so local admin development serves API calls
same-origin and forwards them to the local API process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:23 +01:00
jjbruntonandClaude Fable 5 af4e1d0cf6 test(api): include profile settings migration in identity integration schema
The profile select now includes usage_reason, which 0005_profile_settings.sql adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:09:29 +01:00
jjbruntonandClaude Fable 5 9e6e4612f5 fix(db): journal 0021_dare_proof and renumber solo sealed answers migration to 0022
The dare proof migration file landed on staging without a journal entry, so the
drizzle migrator would never apply it. Register it in the journal and move the
solo sealed answers migration to 0022 to keep filenames and journal order aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:52:58 +01:00
jjbrunton 7d392e756d Merge solo sealed answers that unlock on pairing into staging 2026-08-30 19:49:51 +01:00
jjbrunton 3c08963a70 Merge skippable onboarding avatar with first-match prompt and usage-reason pack picks into staging 2026-08-30 19:49:23 +01:00
jjbrunton d86d57d5c2 Merge branded question share card into staging 2026-08-30 19:38:16 +01:00
jjbruntonandClaude Fable 5 57501230cd feat(share): branded question share card with share analytics
Replace the inline ShareableCard in SharePreviewModal with a designed
QuestionShareCard: Sauci logo + wordmark header, pack badge, and a
branded sauci.app footer pill on the captured story image. The card
deliberately never carries the couple join code - it is a private
pairing credential, not a referral link (a regression test enforces
this). Text-share fallbacks now carry a full https://sauci.app link,
and all three share paths fire a new question_shared analytics event
with the destination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:38:09 +01:00
jjbruntonandClaude Fable 5 986d0b21cb Merge dare proof of completion into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:37:08 +01:00
jjbrunton f52b8536be Merge admin portal coverage for quiz, dares loop, subscriptions, couples, and live draw into staging 2026-08-30 19:35:22 +01:00
jjbruntonandClaude Fable 5 09255da7db feat(pairing): solo sealed answers that unlock on pairing
Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:34:42 +01:00
jjbrunton 8e83cc0d85 feat(admin): add support views for dares, quiz, subscriptions, couples, and live draw
Give support read-only visibility into purchase history, streak locality
fields, notification preferences, the dares loop, couples quiz sessions,
invite-code lookup, and recent live-draw activity, all backed by resources
already allow-listed in the admin repository.
2026-08-30 19:21:56 +01:00
jjbrunton 8955e49aae Merge remote-tracking branch 'origin/staging' into task/peaceful-grothendieck-95a3aa 2026-08-30 19:19:19 +01:00
jjbruntonandClaude Fable 5 04822043ae fix(share): attach captured card image when sharing to Instagram Stories
Both share modals opened the instagram-stories:// scheme without ever
placing the captured PNG on the pasteboard, so Instagram opened an empty
story. Replace the manual scheme launch with react-native-share
shareSingle(INSTAGRAM_STORIES), which writes the image to UIPasteboard
under com.instagram.sharedSticker.backgroundImage on iOS and uses the
ADD_TO_STORY intent on Android. Gate the flow on the required Facebook
App ID (EXPO_PUBLIC_FACEBOOK_APP_ID) and Instagram being installed, with
fallback to the system share sheet, and remove the dead base64 reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:19:01 +01:00
jjbrunton eab73e6314 feat(admin): manage quiz questions and dare intensity
Add a Quiz Questions content page (list/filter/create/edit/activate-
deactivate, no delete since quiz_answers reference questions by id),
route it under manage_questions, and add a sidebar entry.

Fix DaresPage so intensity (1-5) is editable and shown in the table;
previously every admin-created dare silently defaulted to intensity 1.

Add authored min/max intensity fields to DarePacksPage: no trigger or
worker in apps/api computes these columns, so they are admin-set
thresholds gating pack visibility, not derived aggregates. avg_intensity
is displayed read-only since nothing writes to it and it is not used
for filtering.

Extract dare intensity and quiz option validation into small lib
modules with focused unit tests.
2026-08-30 19:13:08 +01:00
jjbruntonandClaude Fable 5 fd8981fa28 feat(onboarding): skippable avatar with first-match prompt and usage-reason pack picks
Lets new users skip the onboarding avatar step and instead defers the ask
to a lightweight, dismissible prompt shown once after the couple's first
match. Also personalizes the home screen's first pack recommendation using
the usage_reason already collected during onboarding, which now round-trips
through GET /v1/me.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:12:56 +01:00
jjbrunton 25b7c6238e feat(admin-api): expose notification_preferences as read-only
Resolve the earlier open question by mapping the profile-settings
domain's notification toggles to the existing notification_preferences
table (primary key user_id, no sensitive columns). Read-only, gated on
view_users, with a test covering read access and rejected writes.
2026-08-30 19:04:54 +01:00
jjbrunton 77f2718fdd feat(admin-api): expose quiz, dares loop, and profile settings resources
Extend the admin allow-list so upcoming admin screens can reach quiz
content and activity, sent dares, and dare chat messages. Also fix a
schema drift where question_packs.scheduled_release_at and
release_notified existed in the database but were missing from the
drizzle TS schema, which could have let a future generate propose
dropping them.

Note: no profile_settings table exists in the schema. The
profile-settings domain reads and writes the existing profiles and
notification_preferences tables, so that entry was intentionally
omitted pending a product decision on which table it should map to.
2026-08-30 19:03:26 +01:00
jjbrunton f38b6f14fa chore(release): promote invite funnel join links to main
Promote invite funnel join links to main
2026-08-30 18:16:36 +01:00
jjbrunton 2cbe9e9c55 chore(release): promote photo picker fix
Promote the verified Android photo-picker policy fix from staging to main after target-branch and pull-request checks passed.
2026-08-30 17:31:02 +01:00
118 changed files with 7855 additions and 933 deletions

No files matched your search

+19 -13
View File
@@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services.
| Service | Port |
|---|---:|
| web | 3000 |
| admin | 3001 |
| MCP | 3002 |
| PostgreSQL | 54320 |
| API | 3003 |
| web | 3000 (overridable with `SAUCI_WEB_PORT`) |
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
| Expo Metro | 8081 |
| Supabase API / DB / Studio | 54321 / 54322 / 54323 |
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and
the repository Supabase CLI dependency.
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
and the configured hosted Auth credentials where an authenticated client needs them.
- `scripts/dev-local.sh up` — idempotently start infra and services.
- `status`, `logs <service>`, `restart <service>`, `attach` — inspect.
- `reset` — local-only database reset plus deterministic fixtures.
- `ios` / `android` — launch the native app against local Supabase.
- `down`; `down --all` — stop services, optionally infrastructure.
- `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
- `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
- `reset`: local-only database reset plus deterministic fixtures.
- `e2e`: start or safely reuse the local stack, validate local endpoints, and
run Playwright with launcher-derived URLs and a loopback PostgreSQL fixture.
- `ios` / `android`: launch the native app against the standalone local API and
configured hosted Auth project.
- `down`; `down --all`: stop services, optionally infrastructure.
If a port is occupied, use `status` and inspect its owning process before stopping
anything. If a tmux window exited, read its logs before restarting it.
Hosted Supabase provides Auth only. Local E2E never starts or writes to a
Supabase data plane. If a port is occupied, use `status` and inspect its owning
process before stopping anything. If a tmux window exited, read its logs before
restarting it.
+7 -2
View File
@@ -36,12 +36,17 @@ Require all of the following:
- a named branch, normally `main` for a production release;
- a clean starting worktree with `HEAD` equal to its fetched upstream;
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
- consistent public versions across `app.json`, Android `versionName`, and every
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
- consistent public and OTA runtime versions across `app.json`, explicit
`app.config.js` runtime, Android, and every iOS target
(`scripts/set_version.mjs --check`);
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
- the configured production API/Auth/RevenueCat environment, without printing
secret values.
For an OTA release, run `npm run ota:preflight:production -w @sauci/mobile`
before any publish. This exercises the EOAS-compatible Expo export environment
without contacting the update server or uploading an update.
Do not switch branches, discard work, repair credentials, push, or weaken branch
protection merely to pass preflight. Stop with the exact blocker. Never run
`expo prebuild` as part of this workflow.
@@ -8,8 +8,13 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../../../..");
const files = {
app: path.join(repoRoot, "apps/mobile/app.json"),
appConfig: path.join(repoRoot, "apps/mobile/app.config.js"),
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
androidExpo: path.join(repoRoot, "apps/mobile/android/app/src/main/res/values/strings.xml"),
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"),
iosExpo: path.join(repoRoot, "apps/mobile/ios/Sauci/Supporting/Expo.plist"),
widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"),
};
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
@@ -25,10 +30,19 @@ function parseVersion(value, label) {
return match.slice(1).map(Number);
}
function writePreservingFinalNewline(file, contents, previous) {
fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, ""));
}
function readState() {
const appRaw = fs.readFileSync(files.app, "utf8");
const appConfigRaw = fs.readFileSync(files.appConfig, "utf8");
const androidRaw = fs.readFileSync(files.android, "utf8");
const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8");
const iosRaw = fs.readFileSync(files.ios, "utf8");
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
const iosExpoRaw = fs.readFileSync(files.iosExpo, "utf8");
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8");
const appVersion = JSON.parse(appRaw).expo?.version;
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
(match) => match[1],
@@ -36,18 +50,63 @@ function readState() {
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
(match) => match[1].trim(),
);
const plistVersion = (raw, label) => {
const versions = [...raw.matchAll(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (versions.length !== 1) fail(`expected exactly one ${label} CFBundleShortVersionString`);
return versions[0];
};
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget Info.plist");
const expoRuntimeVersion = (raw, label) => {
const versions = [...raw.matchAll(/<key>EXUpdatesRuntimeVersion<\/key>\s*<string>([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (versions.length !== 1) fail(`expected exactly one ${label} Expo runtime version`);
return versions[0];
};
const appConfigRuntimeVersions = [...appConfigRaw.matchAll(/runtimeVersion:\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1],
);
const androidRuntimeVersions = [...androidExpoRaw.matchAll(/<string name="expo_runtime_version">([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (appConfigRuntimeVersions.length !== 1) fail("expected exactly one explicit app.config.js runtimeVersion");
if (androidRuntimeVersions.length !== 1) fail("expected exactly one Android Expo runtime version");
const iosExpoVersion = expoRuntimeVersion(iosExpoRaw, "iOS");
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
const versions = [appVersion, ...androidVersions, ...iosVersions];
const versions = [
appVersion,
...androidVersions,
...iosVersions,
iosInfoVersion,
widgetInfoVersion,
appConfigRuntimeVersions[0],
androidRuntimeVersions[0],
iosExpoVersion,
];
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
if (new Set(versions).size !== 1) {
fail(`public versions disagree: ${versions.join(", ")}`);
}
return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length };
return {
appRaw,
appConfigRaw,
androidRaw,
androidExpoRaw,
iosRaw,
iosInfoRaw,
iosExpoRaw,
widgetInfoRaw,
current: appVersion,
iosCount: iosVersions.length,
};
}
function nextVersion(current, bump) {
@@ -69,7 +128,7 @@ if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
const state = readState();
if (check) {
console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
console.log(`public and OTA runtime version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
process.exit(0);
}
@@ -94,12 +153,37 @@ const androidRaw = state.androidRaw.replace(
/(\bversionName\s+["'])([^"']+)(["'])/,
`$1${desired}$3`,
);
const appConfigRaw = state.appConfigRaw.replace(
/(runtimeVersion:\s*['"])([^'"]+)(['"])/,
`$1${desired}$3`,
);
const androidExpoRaw = state.androidExpoRaw.replace(
/(<string name="expo_runtime_version">)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const iosRaw = state.iosRaw.replace(
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
`$1${desired}$3`,
);
const iosInfoRaw = state.iosInfoRaw.replace(
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const iosExpoRaw = state.iosExpoRaw.replace(
/(<key>EXUpdatesRuntimeVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const widgetInfoRaw = state.widgetInfoRaw.replace(
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
fs.writeFileSync(files.app, appRaw);
fs.writeFileSync(files.android, androidRaw);
fs.writeFileSync(files.ios, iosRaw);
writePreservingFinalNewline(files.app, appRaw, state.appRaw);
writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw);
writePreservingFinalNewline(files.android, androidRaw, state.androidRaw);
writePreservingFinalNewline(files.androidExpo, androidExpoRaw, state.androidExpoRaw);
writePreservingFinalNewline(files.ios, iosRaw, state.iosRaw);
writePreservingFinalNewline(files.iosInfo, iosInfoRaw, state.iosInfoRaw);
writePreservingFinalNewline(files.iosExpo, iosExpoRaw, state.iosExpoRaw);
writePreservingFinalNewline(files.widgetInfo, widgetInfoRaw, state.widgetInfoRaw);
console.log(`updated public version ${state.current} -> ${desired}`);
+3 -2
View File
@@ -58,8 +58,9 @@ e2e/playwright-report/
.playwright-mcp/
# Misc
*.pem
.cache/
*.pem
!apps/mobile/certs/certificate.pem
.cache/
.temp/
.tmp/
*.tmp
+36
View File
@@ -11,8 +11,10 @@ import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
import { TagsPage } from '@/pages/content/TagsPage';
import { DarePacksPage } from '@/pages/content/DarePacksPage';
import { DaresPage } from '@/pages/content/DaresPage';
import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage';
import { UsersPage } from '@/pages/users/UsersPage';
import { UserDetailPage } from '@/pages/users/UserDetailPage';
import { CouplesPage } from '@/pages/users/CouplesPage';
import { MatchChatPage } from '@/pages/users/MatchChatPage';
import { AuditLogsPage } from '@/pages/AuditLogsPage';
import { AdminsPage } from '@/pages/admins/AdminsPage';
@@ -20,6 +22,8 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
import { FeedbackPage } from '@/pages/FeedbackPage';
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
import { SentDaresPage } from '@/pages/activity/SentDaresPage';
import { LiveDrawSessionsPage } from '@/pages/activity/LiveDrawSessionsPage';
import { AiSettingsPage } from '@/pages/AiSettingsPage';
import { AppSettingsPage } from '@/pages/AppSettingsPage';
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
@@ -108,6 +112,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/quiz-questions"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
<QuizQuestionsPage />
</ProtectedRoute>
}
/>
{/* User management */}
<Route
@@ -142,6 +154,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/couples"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
<CouplesPage />
</ProtectedRoute>
}
/>
<Route
path="/activity"
element={
@@ -150,6 +170,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/activity/sent-dares"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
<SentDaresPage />
</ProtectedRoute>
}
/>
<Route
path="/activity/live-draw"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
<LiveDrawSessionsPage />
</ProtectedRoute>
}
/>
{/* System */}
<Route
@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react';
export interface StrokePoint {
x: number;
y: number;
}
export interface StrokeSegment {
id: string;
userId: string;
points: StrokePoint[];
color: string;
width: number;
timestamp: number;
isEraser: boolean;
}
export const STROKE_CANVAS_BACKGROUND = '#1a1a2e';
/**
* Renders a `live_draw_sessions.strokes` array to a canvas. Points are stored
* as 0..1 fractions of the drawing surface, so this scales to whatever size
* the canvas element is laid out at.
*/
export function StrokeCanvas({ strokes }: { strokes: StrokeSegment[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = STROKE_CANVAS_BACKGROUND;
ctx.fillRect(0, 0, w, h);
for (const stroke of strokes) {
if (stroke.points.length === 0) continue;
ctx.strokeStyle = stroke.isEraser ? STROKE_CANVAS_BACKGROUND : stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const first = stroke.points[0];
ctx.moveTo(first.x * w, first.y * h);
if (stroke.points.length === 1) {
ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1);
} else {
for (let i = 1; i < stroke.points.length; i++) {
const prev = stroke.points[i - 1];
const curr = stroke.points[i];
const midX = ((prev.x + curr.x) / 2) * w;
const midY = ((prev.y + curr.y) / 2) * h;
if (i === 1) {
ctx.lineTo(midX, midY);
} else {
ctx.quadraticCurveTo(prev.x * w, prev.y * h, midX, midY);
}
}
const last = stroke.points[stroke.points.length - 1];
ctx.lineTo(last.x * w, last.y * h);
}
ctx.stroke();
}
}, [strokes]);
return (
<div className="rounded-xl overflow-hidden border" style={{ background: STROKE_CANVAS_BACKGROUND }}>
<canvas
ref={canvasRef}
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
/>
</div>
);
}
@@ -21,6 +21,10 @@ import {
PieChart,
Tags,
Zap,
HelpCircle,
Heart,
Swords,
Pencil,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -68,6 +72,12 @@ const contentNav: NavItem[] = [
icon: <Tags className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
{
label: 'Quiz Questions',
href: '/quiz-questions',
icon: <HelpCircle className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS,
},
];
const usersNav: NavItem[] = [
@@ -83,6 +93,24 @@ const usersNav: NavItem[] = [
icon: <Activity className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
},
{
label: 'Sent Dares',
href: '/activity/sent-dares',
icon: <Swords className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
},
{
label: 'Live Draw Sessions',
href: '/activity/live-draw',
icon: <Pencil className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
},
{
label: 'Couples',
href: '/couples',
icon: <Heart className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
},
{
label: 'Usage Insights',
href: '/usage-insights',
@@ -7,6 +7,7 @@ const AUDITED_TABLES = [
'categories',
'dare_packs',
'dares',
'quiz_questions',
'profiles',
'couples',
'responses',
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from './couples';
describe('coupleStatusFromMemberCount', () => {
it('treats zero or one member as pending', () => {
expect(coupleStatusFromMemberCount(0)).toBe('pending');
expect(coupleStatusFromMemberCount(1)).toBe('pending');
});
it('treats two or more members as paired', () => {
expect(coupleStatusFromMemberCount(2)).toBe('paired');
expect(coupleStatusFromMemberCount(3)).toBe('paired');
});
});
describe('buildCoupleSearchFilter', () => {
it('returns null for an empty or blank search', () => {
expect(buildCoupleSearchFilter('')).toBeNull();
expect(buildCoupleSearchFilter(' ')).toBeNull();
});
it('matches a uuid-shaped search as an exact id filter', () => {
expect(buildCoupleSearchFilter('a1b2c3d4-1111-1111-1111-111111111111')).toEqual({
column: 'id', op: 'eq', value: 'a1b2c3d4-1111-1111-1111-111111111111',
});
});
it('treats any other search as a partial invite code match', () => {
expect(buildCoupleSearchFilter('SUNSET42')).toEqual({
column: 'invite_code', op: 'ilike', value: '%SUNSET42%',
});
});
it('trims surrounding whitespace before deciding the filter shape', () => {
expect(buildCoupleSearchFilter(' sunset42 ')).toEqual({
column: 'invite_code', op: 'ilike', value: '%sunset42%',
});
});
});
+35
View File
@@ -0,0 +1,35 @@
// Couples do not carry an explicit "paired" flag (see apps/api/src/domains/couples/schema.ts).
// Pairing is inferred from how many profiles currently reference the couple id, the same
// approach UsersPage already uses inline for the couple badge on a profile row.
export type CoupleStatus = 'paired' | 'pending';
/**
* Derive whether a couple is fully paired from the number of profiles that
* reference it. A couple starts with one member (the creator) and becomes
* paired once a partner joins via invite code.
*/
export function coupleStatusFromMemberCount(memberCount: number): CoupleStatus {
return memberCount >= 2 ? 'paired' : 'pending';
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type CoupleSearchFilter =
| { column: 'id'; op: 'eq'; value: string }
| { column: 'invite_code'; op: 'ilike'; value: string }
| null;
/**
* Build the admin query filter for the couples search box. A UUID-shaped
* search term is treated as an exact id lookup; anything else is matched
* against the invite code with a case-insensitive partial match.
*/
export function buildCoupleSearchFilter(rawSearch: string): CoupleSearchFilter {
const search = rawSearch.trim();
if (!search) return null;
if (UUID_PATTERN.test(search)) {
return { column: 'id', op: 'eq', value: search };
}
return { column: 'invite_code', op: 'ilike', value: `%${search}%` };
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { clampDareIntensity, parseIntensityRange } from './dareIntensity';
describe('clampDareIntensity', () => {
it('accepts values within the 1-5 range', () => {
expect(clampDareIntensity('1')).toBe(1);
expect(clampDareIntensity('3')).toBe(3);
expect(clampDareIntensity('5')).toBe(5);
});
it('falls back to 1 for missing, non-numeric, or out-of-range values', () => {
expect(clampDareIntensity('')).toBe(1);
expect(clampDareIntensity('abc')).toBe(1);
expect(clampDareIntensity('0')).toBe(1);
expect(clampDareIntensity('6')).toBe(1);
});
it('falls back to a custom default when provided', () => {
expect(clampDareIntensity('', 3)).toBe(3);
expect(clampDareIntensity('9', 3)).toBe(3);
});
});
describe('parseIntensityRange', () => {
it('allows both bounds blank', () => {
expect(parseIntensityRange('', '')).toEqual({ range: { min: null, max: null }, error: null });
});
it('parses valid bounds', () => {
expect(parseIntensityRange('2', '4')).toEqual({ range: { min: 2, max: 4 }, error: null });
});
it('rejects a minimum outside 1-5', () => {
const { error } = parseIntensityRange('0', '');
expect(error).toMatch(/Minimum intensity/);
});
it('rejects a maximum outside 1-5', () => {
const { error } = parseIntensityRange('', '6');
expect(error).toMatch(/Maximum intensity/);
});
it('rejects a minimum greater than the maximum', () => {
const { error } = parseIntensityRange('4', '2');
expect(error).toMatch(/cannot be greater than/);
});
});
+44
View File
@@ -0,0 +1,44 @@
// Dares and dare packs both gate on a 1-5 intensity scale (see
// apps/api/drizzle/0010_admin.sql and 0013_dares_loop.sql). These helpers keep
// the admin form validation consistent with that range.
export const MIN_DARE_INTENSITY = 1;
export const MAX_DARE_INTENSITY = 5;
/**
* Clamp a raw form value to a valid dare intensity (1-5), falling back to 1
* when the value is missing or not a finite number in range.
*/
export function clampDareIntensity(rawValue: string, fallback: number = MIN_DARE_INTENSITY): number {
const parsed = parseInt(rawValue, 10);
if (!Number.isFinite(parsed) || parsed < MIN_DARE_INTENSITY || parsed > MAX_DARE_INTENSITY) {
return fallback;
}
return parsed;
}
export interface IntensityRange {
min: number | null;
max: number | null;
}
/**
* Parse and validate an optional min/max intensity pair for a dare pack.
* Returns the parsed range, or an error message when the values are invalid.
*/
export function parseIntensityRange(rawMin: string, rawMax: string): { range: IntensityRange; error: string | null } {
const min = rawMin.trim() ? parseInt(rawMin, 10) : null;
const max = rawMax.trim() ? parseInt(rawMax, 10) : null;
if (min !== null && (!Number.isFinite(min) || min < MIN_DARE_INTENSITY || min > MAX_DARE_INTENSITY)) {
return { range: { min: null, max: null }, error: 'Minimum intensity must be between 1 and 5' };
}
if (max !== null && (!Number.isFinite(max) || max < MIN_DARE_INTENSITY || max > MAX_DARE_INTENSITY)) {
return { range: { min: null, max: null }, error: 'Maximum intensity must be between 1 and 5' };
}
if (min !== null && max !== null && min > max) {
return { range: { min: null, max: null }, error: 'Minimum intensity cannot be greater than maximum intensity' };
}
return { range: { min, max }, error: null };
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { sanitizeQuizOptions, validateQuizQuestionForm } from './quizQuestions';
describe('sanitizeQuizOptions', () => {
it('trims and drops empty options', () => {
expect(sanitizeQuizOptions([' A ', '', ' ', 'B'])).toEqual(['A', 'B']);
});
});
describe('validateQuizQuestionForm', () => {
const base = {
prompt_self: 'What is your favourite season?',
prompt_guess: "What is your partner's favourite season?",
options: ['Spring', 'Summer'],
};
it('accepts a valid form', () => {
expect(validateQuizQuestionForm(base)).toBeNull();
});
it('requires the self prompt', () => {
expect(validateQuizQuestionForm({ ...base, prompt_self: ' ' })).toMatch(/about yourself/);
});
it('requires the guess prompt', () => {
expect(validateQuizQuestionForm({ ...base, prompt_guess: '' })).toMatch(/about your partner/);
});
it('requires at least two options', () => {
expect(validateQuizQuestionForm({ ...base, options: ['Only one'] })).toMatch(/At least 2 options/);
});
it('allows at most four options', () => {
expect(validateQuizQuestionForm({ ...base, options: ['A', 'B', 'C', 'D', 'E'] })).toMatch(/At most 4 options/);
});
it('ignores blank options when counting', () => {
expect(validateQuizQuestionForm({ ...base, options: ['Spring', 'Summer', '', ''] })).toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
// Quiz question options are constrained by the database check
// `array_length(options, 1) between 2 and 4` (see
// apps/api/drizzle/0019_quiz.sql). These helpers keep the admin form
// validation consistent with that constraint.
export const MIN_QUIZ_OPTIONS = 2;
export const MAX_QUIZ_OPTIONS = 4;
export interface QuizQuestionFormValues {
prompt_self: string;
prompt_guess: string;
options: string[];
}
/** Trim options and drop any that are empty. */
export function sanitizeQuizOptions(options: string[]): string[] {
return options.map((option) => option.trim()).filter(Boolean);
}
/**
* Validate a quiz question form. Returns an error message, or null when the
* form is valid.
*/
export function validateQuizQuestionForm(form: QuizQuestionFormValues): string | null {
if (!form.prompt_self.trim()) return 'The "about yourself" prompt is required';
if (!form.prompt_guess.trim()) return 'The "about your partner" prompt is required';
const cleanedOptions = sanitizeQuizOptions(form.options);
if (cleanedOptions.length < MIN_QUIZ_OPTIONS) return `At least ${MIN_QUIZ_OPTIONS} options are required`;
if (cleanedOptions.length > MAX_QUIZ_OPTIONS) return `At most ${MAX_QUIZ_OPTIONS} options are allowed`;
return null;
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isSentDareOverdue, SENT_DARE_STATUS_LABELS } from './sentDares';
describe('SENT_DARE_STATUS_LABELS', () => {
it('has a label for every documented status', () => {
for (const status of ['pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled'] as const) {
expect(SENT_DARE_STATUS_LABELS[status].label).toBeTruthy();
}
});
});
describe('isSentDareOverdue', () => {
const now = new Date('2026-08-30T12:00:00Z');
it('is false when there is no expiry', () => {
expect(isSentDareOverdue('pending', null, now)).toBe(false);
});
it('is false for a terminal status even if expiry has passed', () => {
expect(isSentDareOverdue('completed', '2026-08-01T00:00:00Z', now)).toBe(false);
expect(isSentDareOverdue('expired', '2026-08-01T00:00:00Z', now)).toBe(false);
});
it('is false for an open status whose expiry has not passed', () => {
expect(isSentDareOverdue('active', '2026-09-01T00:00:00Z', now)).toBe(false);
});
it('is true for an open status whose expiry has already passed', () => {
expect(isSentDareOverdue('pending', '2026-08-01T00:00:00Z', now)).toBe(true);
expect(isSentDareOverdue('active', '2026-08-01T00:00:00Z', now)).toBe(true);
expect(isSentDareOverdue('submitted', '2026-08-01T00:00:00Z', now)).toBe(true);
});
});
+38
View File
@@ -0,0 +1,38 @@
// sent_dares status lifecycle mirrors the check constraint added in
// apps/api/drizzle/0013_dares_loop.sql: pending -> active -> submitted -> completed,
// with expired/declined/cancelled as terminal side branches.
export const SENT_DARE_STATUSES = [
'pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled',
] as const;
export type SentDareStatus = typeof SENT_DARE_STATUSES[number];
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
export const SENT_DARE_STATUS_LABELS: Record<SentDareStatus, { label: string; variant: BadgeVariant }> = {
pending: { label: 'Pending', variant: 'secondary' },
active: { label: 'Active', variant: 'default' },
submitted: { label: 'Submitted', variant: 'default' },
completed: { label: 'Completed', variant: 'outline' },
expired: { label: 'Expired', variant: 'destructive' },
declined: { label: 'Declined', variant: 'destructive' },
cancelled: { label: 'Cancelled', variant: 'outline' },
};
const OPEN_STATUSES: ReadonlySet<SentDareStatus> = new Set(['pending', 'active', 'submitted']);
/**
* A dare is overdue when it is still in an open state but its expiry has
* already passed. The worker sweeps these to `expired` asynchronously, so the
* admin view can lag the true status briefly; this flags that gap for support.
*/
export function isSentDareOverdue(
status: string,
expiresAt: string | null,
now: Date = new Date(),
): boolean {
if (!expiresAt) return false;
if (!OPEN_STATUSES.has(status as SentDareStatus)) return false;
return new Date(expiresAt).getTime() < now.getTime();
}
@@ -0,0 +1,222 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { adminData } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Pencil, Eye } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { StrokeCanvas, type StrokeSegment } from '@/components/content/StrokeCanvas';
interface LiveDrawSession {
couple_id: string;
revision: number;
updated_by: string;
created_at: string;
updated_at: string;
}
interface Profile {
id: string;
name: string | null;
email: string | null;
couple_id: string | null;
}
export function LiveDrawSessionsPage() {
const [sessions, setSessions] = useState<LiveDrawSession[]>([]);
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [total, setTotal] = useState(0);
const [selectedCoupleId, setSelectedCoupleId] = useState<string | null>(null);
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
const [strokesLoading, setStrokesLoading] = useState(false);
const fetchSessions = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data, error, count } = await adminData
.from<LiveDrawSession>('live_draw_sessions')
.select('couple_id, revision, updated_by, created_at, updated_at', { count: 'exact' })
.order('updated_at', { ascending: false })
.range(from, to);
if (error) throw error;
const rows = data || [];
setSessions(rows);
setTotal(count || 0);
const coupleIds = rows.map((row) => row.couple_id);
if (coupleIds.length > 0) {
const { data: profiles } = await adminData
.from<Profile>('profiles')
.select('id, name, email, couple_id')
.in('couple_id', coupleIds);
const grouped: Record<string, Profile[]> = {};
(profiles || []).forEach((profile) => {
if (!profile.couple_id) return;
(grouped[profile.couple_id] ??= []).push(profile);
});
setMembersByCouple(grouped);
} else {
setMembersByCouple({});
}
} catch (error) {
console.error('Failed to load live draw sessions:', error);
toast.error('Failed to load live draw sessions');
} finally {
setLoading(false);
}
}, [page, pageSize]);
useEffect(() => { fetchSessions(); }, [fetchSessions]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (page > totalPages) setPage(totalPages);
}, [page, pageSize, total]);
const previewSession = async (coupleId: string) => {
setSelectedCoupleId(coupleId);
setStrokesLoading(true);
setStrokes([]);
try {
const { data, error } = await adminData
.from('live_draw_sessions')
.select('strokes')
.eq('couple_id', coupleId)
.maybeSingle();
if (error) throw error;
setStrokes((data?.strokes as StrokeSegment[]) || []);
} catch (error) {
console.error('Failed to load drawing:', error);
toast.error('Failed to load drawing');
} finally {
setStrokesLoading(false);
}
};
const renderMembers = (coupleId: string) => {
const members = membersByCouple[coupleId] || [];
if (members.length === 0) return <span className="text-muted-foreground text-sm">Unknown couple</span>;
return (
<div className="flex flex-col gap-1">
{members.map((member) => (
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
{member.name || member.email || member.id}
</Link>
))}
</div>
);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Pencil className="h-8 w-8 text-primary" />
Live Draw Sessions
</h1>
<p className="text-muted-foreground">
Recent live-draw activity across couples, most recently updated first
</p>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Couple</TableHead>
<TableHead>Revision</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No live draw sessions yet.
</TableCell>
</TableRow>
) : (
sessions.map((session) => (
<TableRow key={session.couple_id}>
<TableCell>{renderMembers(session.couple_id)}</TableCell>
<TableCell><Badge variant="outline">{session.revision}</Badge></TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(session.created_at), 'MMM d, yyyy')}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(session.updated_at), 'MMM d, yyyy HH:mm')}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => previewSession(session.couple_id)}>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{total > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={total}
onPageChange={setPage}
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
/>
)}
{selectedCoupleId && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Drawing Preview</CardTitle>
<CardDescription>Read-only snapshot of the couple's shared canvas</CardDescription>
</CardHeader>
<CardContent>
{strokesLoading ? (
<Skeleton className="h-64 w-full" />
) : strokes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Pencil className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No strokes to show</p>
</div>
) : (
<StrokeCanvas strokes={strokes} />
)}
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,289 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { adminData } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Swords, Eye, MessageSquare } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { SENT_DARE_STATUS_LABELS, SENT_DARE_STATUSES, isSentDareOverdue, type SentDareStatus } from '@/lib/sentDares';
interface Profile {
id: string;
name: string | null;
email: string | null;
}
interface SentDare {
id: string;
couple_id: string;
dare_text_snapshot: string;
custom_dare_text: string | null;
dare_intensity_snapshot: number;
sender_id: string;
recipient_id: string;
status: string;
sent_at: string;
expires_at: string | null;
}
interface DareMessage {
id: string;
sender_id: string;
content: string;
created_at: string;
read_at: string | null;
}
const STATUS_FILTER_OPTIONS: Array<'all' | SentDareStatus> = ['all', ...SENT_DARE_STATUSES];
export function SentDaresPage() {
const [dares, setDares] = useState<SentDare[]>([]);
const [profilesById, setProfilesById] = useState<Record<string, Profile>>({});
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<'all' | SentDareStatus>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [total, setTotal] = useState(0);
const [selectedDare, setSelectedDare] = useState<SentDare | null>(null);
const [messages, setMessages] = useState<DareMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const fetchDares = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
let query = adminData
.from<SentDare>('sent_dares')
.select('id, couple_id, dare_text_snapshot, custom_dare_text, dare_intensity_snapshot, sender_id, recipient_id, status, sent_at, expires_at', { count: 'exact' })
.order('sent_at', { ascending: false })
.range(from, to);
if (statusFilter !== 'all') {
query = query.eq('status', statusFilter);
}
const { data, error, count } = await query;
if (error) throw error;
const rows = data || [];
setDares(rows);
setTotal(count || 0);
const userIds = [...new Set(rows.flatMap((row) => [row.sender_id, row.recipient_id]))];
if (userIds.length > 0) {
const { data: profiles } = await adminData
.from<Profile>('profiles')
.select('id, name, email')
.in('id', userIds);
setProfilesById((profiles || []).reduce((acc: Record<string, Profile>, profile) => {
acc[profile.id] = profile;
return acc;
}, {}));
} else {
setProfilesById({});
}
} catch (error) {
console.error('Failed to load sent dares:', error);
toast.error('Failed to load sent dares');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter]);
useEffect(() => { fetchDares(); }, [fetchDares]);
useEffect(() => { setPage(1); }, [statusFilter]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (page > totalPages) setPage(totalPages);
}, [page, pageSize, total]);
const renderUserLink = (userId: string) => {
const profile = profilesById[userId];
return (
<Link to={`/users/${userId}`} className="text-primary hover:underline">
{profile?.name || profile?.email || 'Unknown user'}
</Link>
);
};
const openDetail = async (dare: SentDare) => {
setSelectedDare(dare);
setMessagesLoading(true);
setMessages([]);
try {
const { data, error } = await adminData
.from<DareMessage>('dare_messages')
.select('id, sender_id, content, created_at, read_at')
.eq('sent_dare_id', dare.id)
.order('created_at', { ascending: true });
if (error) throw error;
setMessages(data || []);
} catch (error) {
console.error('Failed to load dare messages:', error);
toast.error('Failed to load dare messages');
} finally {
setMessagesLoading(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Swords className="h-8 w-8 text-primary" />
Sent Dares
</h1>
<p className="text-muted-foreground">
Review the dares loop between couples for moderation and support
</p>
</div>
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as 'all' | SentDareStatus)}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map((status) => (
<SelectItem key={status} value={status} className="capitalize">
{status === 'all' ? 'All statuses' : SENT_DARE_STATUS_LABELS[status].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Dare</TableHead>
<TableHead>Sender</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead>
<TableHead>Sent</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : dares.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No sent dares found.
</TableCell>
</TableRow>
) : (
dares.map((dare) => {
const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus];
const overdue = isSentDareOverdue(dare.status, dare.expires_at);
return (
<TableRow key={dare.id}>
<TableCell className="max-w-[280px]">
<span className="line-clamp-2 block">{dare.custom_dare_text || dare.dare_text_snapshot}</span>
</TableCell>
<TableCell>{renderUserLink(dare.sender_id)}</TableCell>
<TableCell>{renderUserLink(dare.recipient_id)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant={statusInfo?.variant ?? 'secondary'} className="capitalize">{statusInfo?.label ?? dare.status}</Badge>
{overdue && <Badge variant="destructive">Overdue</Badge>}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(dare.sent_at), 'MMM d, yyyy HH:mm')}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{dare.expires_at ? format(new Date(dare.expires_at), 'MMM d, yyyy HH:mm') : 'No expiry'}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => openDetail(dare)}>
<Eye className="h-4 w-4 mr-2" />
Messages
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</Card>
{total > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={total}
onPageChange={setPage}
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
/>
)}
<Dialog open={!!selectedDare} onOpenChange={(open) => !open && setSelectedDare(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Dare Messages
</DialogTitle>
<DialogDescription>
{selectedDare && (selectedDare.custom_dare_text || selectedDare.dare_text_snapshot)}
</DialogDescription>
</DialogHeader>
<div className="min-h-[120px] max-h-[400px] overflow-y-auto space-y-3 bg-muted/30 rounded-md p-4">
{messagesLoading ? (
<Skeleton className="h-24 w-full" />
) : messages.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No messages for this dare</p>
) : (
messages.map((message) => (
<div key={message.id} className="bg-background border rounded p-3 text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">
{selectedDare && (message.sender_id === selectedDare.sender_id ? 'Sender' : 'Recipient')}
</span>
<span className="text-xs text-muted-foreground">
{format(new Date(message.created_at), 'MMM d, yyyy HH:mm')}
</span>
</div>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -33,6 +33,7 @@ import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, Chev
import { toast } from 'sonner';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
import { parseIntensityRange } from '@/lib/dareIntensity';
// =============================================================================
// Types
@@ -54,6 +55,9 @@ interface DarePack {
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
created_at: string | null;
dare_count?: number;
content_status?: ContentReviewStatus | null;
@@ -68,6 +72,8 @@ interface DarePackFormData {
is_public: boolean;
is_explicit: boolean;
category_id: string;
min_intensity: string;
max_intensity: string;
}
// =============================================================================
@@ -96,6 +102,8 @@ export function DarePacksPage() {
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
min_intensity: '',
max_intensity: '',
},
(pack) => ({
name: pack.name,
@@ -105,6 +113,8 @@ export function DarePacksPage() {
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
min_intensity: pack.min_intensity != null ? String(pack.min_intensity) : '',
max_intensity: pack.max_intensity != null ? String(pack.max_intensity) : '',
})
);
@@ -209,6 +219,14 @@ export function DarePacksPage() {
return;
}
const { range, error: intensityError } = parseIntensityRange(form.formData.min_intensity, form.formData.max_intensity);
if (intensityError) {
toast.error(intensityError);
return;
}
const parsedMin = range.min;
const parsedMax = range.max;
form.setSaving(true);
try {
if (form.editingItem) {
@@ -220,6 +238,8 @@ export function DarePacksPage() {
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
min_intensity: parsedMin,
max_intensity: parsedMax,
});
if (error) throw error;
toast.success('Dare pack updated');
@@ -232,6 +252,8 @@ export function DarePacksPage() {
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
min_intensity: parsedMin,
max_intensity: parsedMax,
sort_order: totalCount,
});
if (error) throw error;
@@ -410,6 +432,36 @@ export function DarePacksPage() {
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="min_intensity">Min Intensity (optional)</Label>
<Input
id="min_intensity"
type="number"
min={1}
max={5}
value={form.formData.min_intensity}
onChange={(e) => form.setField('min_intensity', e.target.value)}
placeholder="1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_intensity">Max Intensity (optional)</Label>
<Input
id="max_intensity"
type="number"
min={1}
max={5}
value={form.formData.max_intensity}
onChange={(e) => form.setField('max_intensity', e.target.value)}
placeholder="5"
/>
</div>
</div>
<p className="text-xs text-muted-foreground -mt-2">
Gates whether this pack shows for a couple based on their intensity preference. Leave blank for no gate.
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Premium</Label>
@@ -572,6 +624,20 @@ export function DarePacksPage() {
</CardDescription>
</CardHeader>
<CardContent>
{(pack.min_intensity != null || pack.max_intensity != null || pack.avg_intensity != null) && (
<div className="flex flex-wrap gap-1 mb-2">
{(pack.min_intensity != null || pack.max_intensity != null) && (
<Badge variant="outline" className="text-xs">
Intensity {pack.min_intensity ?? 1}-{pack.max_intensity ?? 5}
</Badge>
)}
{pack.avg_intensity != null && (
<Badge variant="outline" className="text-xs">
Avg {pack.avg_intensity}
</Badge>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
+39 -1
View File
@@ -39,6 +39,7 @@ import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
import { clampDareIntensity, MAX_DARE_INTENSITY, MIN_DARE_INTENSITY } from '@/lib/dareIntensity';
// =============================================================================
// Types
@@ -102,9 +103,11 @@ export function DaresPage() {
// Form state
const [formData, setFormData] = useState<{
text: string;
intensity: string;
suggested_duration_hours: string;
}>({
text: '',
intensity: '1',
suggested_duration_hours: '',
});
@@ -166,7 +169,7 @@ export function DaresPage() {
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', suggested_duration_hours: '' });
setFormData({ text: '', intensity: '1', suggested_duration_hours: '' });
setDialogOpen(true);
};
@@ -174,6 +177,7 @@ export function DaresPage() {
setEditingDare(dare);
setFormData({
text: dare.text,
intensity: String(dare.intensity ?? 1),
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
});
setDialogOpen(true);
@@ -185,10 +189,13 @@ export function DaresPage() {
return;
}
const intensity = clampDareIntensity(formData.intensity);
setSaving(true);
try {
const dareData = {
text: formData.text,
intensity,
suggested_duration_hours: formData.suggested_duration_hours
? parseInt(formData.suggested_duration_hours, 10)
: null,
@@ -338,6 +345,31 @@ export function DaresPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="intensity">Intensity</Label>
<Select
value={formData.intensity}
onValueChange={(value) => setFormData(d => ({ ...d, intensity: value }))}
>
<SelectTrigger id="intensity">
<SelectValue placeholder="Select an intensity" />
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: MAX_DARE_INTENSITY - MIN_DARE_INTENSITY + 1 },
(_, i) => MIN_DARE_INTENSITY + i,
).map((level) => (
<SelectItem key={level} value={String(level)}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
1 is mild, 5 is the most intense. Used to gate dares against a couple's intensity preference.
</p>
</div>
<div className="space-y-2">
<Label>Suggested Duration</Label>
<Select
@@ -444,6 +476,7 @@ export function DaresPage() {
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-40">Catalogue</TableHead>
<TableHead className="w-20">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
@@ -478,6 +511,11 @@ export function DaresPage() {
compact
/>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{dare.intensity}
</Badge>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
@@ -0,0 +1,487 @@
import { useState, useEffect, useCallback } from 'react';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Loader2, HelpCircle, X, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { MAX_QUIZ_OPTIONS, MIN_QUIZ_OPTIONS, sanitizeQuizOptions, validateQuizQuestionForm } from '@/lib/quizQuestions';
// =============================================================================
// Types
// =============================================================================
interface QuizQuestion {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
sort_order: number;
is_active: boolean;
created_at: string | null;
}
interface QuizQuestionFormData {
prompt_self: string;
prompt_guess: string;
options: string[];
sort_order: string;
is_active: boolean;
}
type ActiveFilter = 'all' | 'active' | 'inactive';
const emptyOptions = () => ['', ''];
// =============================================================================
// Component
// =============================================================================
export function QuizQuestionsPage() {
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('all');
const form = useEntityForm<QuizQuestionFormData, QuizQuestion>(
{
prompt_self: '',
prompt_guess: '',
options: emptyOptions(),
sort_order: '0',
is_active: true,
},
(question) => ({
prompt_self: question.prompt_self,
prompt_guess: question.prompt_guess,
options: question.options.length ? [...question.options] : emptyOptions(),
sort_order: String(question.sort_order),
is_active: question.is_active,
})
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
let query = adminData
.from('quiz_questions')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (activeFilter === 'active') {
query = query.eq('is_active', true);
} else if (activeFilter === 'inactive') {
query = query.eq('is_active', false);
}
const { data, error, count } = await query;
if (error) throw error;
setTotalCount(count || 0);
setQuestions((data as QuizQuestion[]) || []);
} catch (error) {
toast.error('Failed to load quiz questions');
console.error(error);
} finally {
setLoading(false);
}
}, [page, pageSize, activeFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
}, [activeFilter]);
// =============================================================================
// Option helpers
// =============================================================================
const setOption = (index: number, value: string) => {
form.setFields({
options: form.formData.options.map((opt, i) => (i === index ? value : opt)),
});
};
const addOption = () => {
if (form.formData.options.length >= MAX_QUIZ_OPTIONS) return;
form.setFields({ options: [...form.formData.options, ''] });
};
const removeOption = (index: number) => {
if (form.formData.options.length <= MIN_QUIZ_OPTIONS) return;
form.setFields({ options: form.formData.options.filter((_, i) => i !== index) });
};
// =============================================================================
// Handlers
// =============================================================================
const handleSave = async () => {
const validationError = validateQuizQuestionForm(form.formData);
if (validationError) {
toast.error(validationError);
return;
}
const cleanedOptions = sanitizeQuizOptions(form.formData.options);
const parsedSortOrder = Number(form.formData.sort_order);
const sortOrder = Number.isFinite(parsedSortOrder) ? parsedSortOrder : 0;
const payload = {
prompt_self: form.formData.prompt_self.trim(),
prompt_guess: form.formData.prompt_guess.trim(),
options: cleanedOptions,
sort_order: sortOrder,
is_active: form.formData.is_active,
};
form.setSaving(true);
try {
if (form.editingItem) {
const { error } = await auditedAdminData.update('quiz_questions', form.editingItem.id, payload);
if (error) throw error;
toast.success('Quiz question updated');
} else {
const { error } = await auditedAdminData.insert('quiz_questions', payload);
if (error) throw error;
toast.success('Quiz question created');
}
form.close();
fetchData();
} catch (error) {
toast.error('Failed to save quiz question');
console.error(error);
} finally {
form.setSaving(false);
}
};
const handleToggleActive = async (question: QuizQuestion) => {
try {
const { error } = await auditedAdminData.update('quiz_questions', question.id, {
is_active: !question.is_active,
});
if (error) throw error;
toast.success(question.is_active ? 'Quiz question deactivated' : 'Quiz question activated');
fetchData();
} catch (error) {
toast.error('Failed to update quiz question');
console.error(error);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<HelpCircle className="h-8 w-8 text-purple-500" />
Quiz Questions
</h1>
<p className="text-muted-foreground">
{totalCount} question{totalCount !== 1 ? 's' : ''} in the couples quiz pool
</p>
</div>
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{form.isEditing ? 'Edit Quiz Question' : 'Add Quiz Question'}
</DialogTitle>
<DialogDescription>
{form.isEditing
? 'Update the quiz question details below.'
: 'Add a new question to the couples quiz pool.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="prompt_self">Prompt (about yourself)</Label>
<Textarea
id="prompt_self"
value={form.formData.prompt_self}
onChange={(e) => form.setField('prompt_self', e.target.value)}
placeholder="e.g., What is your idea of a perfect date night?"
rows={2}
/>
<p className="text-xs text-muted-foreground">
Shown when a partner answers about themselves.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt_guess">Prompt (guessing about partner)</Label>
<Textarea
id="prompt_guess"
value={form.formData.prompt_guess}
onChange={(e) => form.setField('prompt_guess', e.target.value)}
placeholder="e.g., What is your partner's idea of a perfect date night?"
rows={2}
/>
<p className="text-xs text-muted-foreground">
Shown when a partner guesses the other partner's answer. Same options, different phrasing.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Options ({MIN_QUIZ_OPTIONS}-{MAX_QUIZ_OPTIONS})</Label>
{form.formData.options.length < MAX_QUIZ_OPTIONS && (
<Button type="button" variant="outline" size="sm" onClick={addOption}>
<Plus className="mr-1 h-3 w-3" />
Add Option
</Button>
)}
</div>
<div className="space-y-2">
{form.formData.options.map((option, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={option}
onChange={(e) => setOption(index, e.target.value)}
placeholder={`Option ${index + 1}`}
/>
{form.formData.options.length > MIN_QUIZ_OPTIONS && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeOption(index)}
aria-label={`Remove option ${index + 1}`}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Both partners choose from the same list of options.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="sort_order">Sort Order</Label>
<Input
id="sort_order"
type="number"
value={form.formData.sort_order}
onChange={(e) => form.setField('sort_order', e.target.value)}
placeholder="0"
/>
<p className="text-xs text-muted-foreground">
Lower numbers are considered before higher numbers when questions have equal usage history.
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Active</Label>
<p className="text-xs text-muted-foreground">
Inactive questions are never selected for a new quiz session.
</p>
</div>
<Switch
checked={form.formData.is_active}
onCheckedChange={(checked) => form.setField('is_active', checked)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={form.close}>
Cancel
</Button>
<Button onClick={handleSave} disabled={form.saving}>
{form.saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-wrap items-center gap-3">
<Label className="text-sm text-muted-foreground">Status</Label>
<Select value={activeFilter} onValueChange={(value) => setActiveFilter(value as ActiveFilter)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All questions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All questions</SelectItem>
<SelectItem value="active">Active only</SelectItem>
<SelectItem value="inactive">Inactive only</SelectItem>
</SelectContent>
</Select>
</div>
{/* Quiz Questions Table */}
{questions.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<HelpCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No quiz questions yet</h3>
<p className="text-muted-foreground mb-4">
Add questions to build the couples quiz pool
</p>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Options</TableHead>
<TableHead className="w-24">Status</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium text-muted-foreground">
{question.sort_order}
</TableCell>
<TableCell>
<p className="line-clamp-2 font-medium">{question.prompt_self}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">{question.prompt_guess}</p>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{question.options.map((option, i) => (
<Badge key={i} variant="outline" className="text-xs">
{option}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant={question.is_active ? 'default' : 'secondary'}>
{question.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => form.openEdit(question)}
aria-label="Edit question"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleActive(question)}
aria-label={question.is_active ? 'Deactivate question' : 'Activate question'}
title={question.is_active ? 'Deactivate' : 'Activate'}
>
{question.is_active ? (
<EyeOff className="h-4 w-4 text-destructive" />
) : (
<Eye className="h-4 w-4 text-green-500" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{questions.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { adminData } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Heart, Search, Users, Hourglass } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from '@/lib/couples';
interface Couple {
id: string;
invite_code: string;
created_at: string;
}
interface Profile {
id: string;
name: string | null;
email: string | null;
couple_id: string | null;
}
export function CouplesPage() {
const [couples, setCouples] = useState<Couple[]>([]);
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [total, setTotal] = useState(0);
const fetchCouples = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
let query = adminData
.from<Couple>('couples')
.select('id, invite_code, created_at', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to);
const filter = buildCoupleSearchFilter(search);
if (filter) {
query = filter.op === 'eq' ? query.eq(filter.column, filter.value) : query.ilike(filter.column, filter.value);
}
const { data, error, count } = await query;
if (error) throw error;
const rows = data || [];
setCouples(rows);
setTotal(count || 0);
const coupleIds = rows.map((row) => row.id);
if (coupleIds.length > 0) {
const { data: profiles } = await adminData
.from<Profile>('profiles')
.select('id, name, email, couple_id')
.in('couple_id', coupleIds);
const grouped: Record<string, Profile[]> = {};
(profiles || []).forEach((profile) => {
if (!profile.couple_id) return;
(grouped[profile.couple_id] ??= []).push(profile);
});
setMembersByCouple(grouped);
} else {
setMembersByCouple({});
}
} catch (error) {
console.error('Failed to load couples:', error);
toast.error('Failed to load couples');
} finally {
setLoading(false);
}
}, [page, pageSize, search]);
useEffect(() => { fetchCouples(); }, [fetchCouples]);
useEffect(() => { setPage(1); }, [search]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (page > totalPages) setPage(totalPages);
}, [page, pageSize, total]);
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Heart className="h-8 w-8 text-primary" />
Couples
</h1>
<p className="text-muted-foreground">
Look up an invite funnel by invite code or couple id
</p>
</div>
<div className="relative w-full sm:w-72">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search invite code or couple id..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Invite Code</TableHead>
<TableHead>Members</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : couples.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
{search ? 'No couples found matching your search.' : 'No couples yet.'}
</TableCell>
</TableRow>
) : (
couples.map((couple) => {
const members = membersByCouple[couple.id] || [];
const status = coupleStatusFromMemberCount(members.length);
return (
<TableRow key={couple.id}>
<TableCell className="font-mono">{couple.invite_code}</TableCell>
<TableCell>
{members.length === 0 ? (
<span className="text-muted-foreground text-sm">No members</span>
) : (
<div className="flex flex-col gap-1">
{members.map((member) => (
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
{member.name || member.email || member.id}
</Link>
))}
</div>
)}
</TableCell>
<TableCell>
{status === 'paired' ? (
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
<Users className="h-3 w-3 mr-1" />
Paired
</Badge>
) : (
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
<Hourglass className="h-3 w-3 mr-1" />
Pending
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(couple.created_at), 'MMM d, yyyy')}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</Card>
{total > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={total}
onPageChange={setPage}
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
/>
)}
</div>
);
}
+272 -77
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
@@ -37,12 +37,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react';
import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil, CreditCard, Bell, Globe, BadgeCheck, HelpCircle, Swords } from 'lucide-react';
import { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker';
import type { AnswerType, QuestionType } from '@sauci/shared';
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
import { StrokeCanvas, type StrokeSegment } from '@/components/content/StrokeCanvas';
import { SENT_DARE_STATUS_LABELS, type SentDareStatus } from '@/lib/sentDares';
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
@@ -61,6 +63,7 @@ interface Profile {
usage_reason: UsageReason | null;
hide_nsfw: boolean | null;
onboarding_completed: boolean | null;
timezone: string | null;
}
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
@@ -158,11 +161,72 @@ interface CoupleStreak {
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
last_milestone_celebrated: number | null;
created_at: string;
updated_at: string;
}
interface NotificationPreferences {
matches_enabled: boolean;
messages_enabled: boolean;
partner_activity_enabled: boolean;
nudges_enabled: boolean;
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
streak_reminders_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
dares_enabled: boolean;
}
const notificationPreferenceLabels: Record<keyof NotificationPreferences, string> = {
matches_enabled: 'Matches',
messages_enabled: 'Messages',
partner_activity_enabled: 'Partner activity',
nudges_enabled: 'Nudges',
pack_changes_enabled: 'Pack changes',
new_packs_enabled: 'New packs',
streak_milestones_enabled: 'Streak milestones',
streak_reminders_enabled: 'Streak reminders',
weekly_summary_enabled: 'Weekly summary',
unpaired_reminders_enabled: 'Unpaired reminders',
catchup_reminders_enabled: 'Catch-up reminders',
dares_enabled: 'Dares',
};
interface Subscription {
id: string;
store: string;
product_id: string;
status: string;
entitlement_ids: string[];
purchased_at: string;
expires_at: string | null;
revenuecat_app_user_id: string;
}
interface QuizSession {
id: string;
status: string;
score_percent: number | null;
completed_at: string | null;
created_at: string;
}
interface SentDare {
id: string;
dare_text_snapshot: string;
custom_dare_text: string | null;
sender_id: string;
recipient_id: string;
status: string;
sent_at: string;
expires_at: string | null;
}
const matchTypeLabels = {
yes_yes: 'Both Yes!',
yes_maybe: 'Yes + Maybe',
@@ -269,25 +333,7 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
}
interface StrokePoint {
x: number;
y: number;
}
interface StrokeSegment {
id: string;
userId: string;
points: StrokePoint[];
color: string;
width: number;
timestamp: number;
isEraser: boolean;
}
const CANVAS_BACKGROUND = '#1a1a2e';
function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -322,56 +368,6 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
};
}, [coupleId]);
// Render strokes to canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = CANVAS_BACKGROUND;
ctx.fillRect(0, 0, w, h);
for (const stroke of strokes) {
if (stroke.points.length === 0) continue;
ctx.strokeStyle = stroke.isEraser ? CANVAS_BACKGROUND : stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const first = stroke.points[0];
ctx.moveTo(first.x * w, first.y * h);
if (stroke.points.length === 1) {
ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1);
} else {
for (let i = 1; i < stroke.points.length; i++) {
const prev = stroke.points[i - 1];
const curr = stroke.points[i];
const midX = ((prev.x + curr.x) / 2) * w;
const midY = ((prev.y + curr.y) / 2) * h;
if (i === 1) {
ctx.lineTo(midX, midY);
} else {
ctx.quadraticCurveTo(prev.x * w, prev.y * h, midX, midY);
}
}
const last = stroke.points[stroke.points.length - 1];
ctx.lineTo(last.x * w, last.y * h);
}
ctx.stroke();
}
}, [strokes]);
if (loading) return <Skeleton className="h-64 w-full" />;
if (error) return <div className="text-sm text-red-500">Failed to load drawing: {error}</div>;
@@ -384,13 +380,8 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
</Card>
) : (
<>
<div className="rounded-xl overflow-hidden border" style={{ background: CANVAS_BACKGROUND }}>
<canvas
ref={canvasRef}
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
/>
</div>
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} — updates in real-time</p>
<StrokeCanvas strokes={strokes} />
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} - updates in real-time</p>
</>
)}
</div>
@@ -424,6 +415,10 @@ export function UserDetailPage() {
const [mediaPage, setMediaPage] = useState(1);
const [mediaPageSize, setMediaPageSize] = useState(12);
const [mediaTotal, setMediaTotal] = useState(0);
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPreferences | null>(null);
const [quizSessions, setQuizSessions] = useState<QuizSession[]>([]);
const [sentDares, setSentDares] = useState<SentDare[]>([]);
const [loading, setLoading] = useState(true);
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
@@ -492,6 +487,39 @@ export function UserDetailPage() {
.order('created_at', { ascending: false });
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
// Fetch purchase history so support can see what was bought and when it lapses,
// instead of only the profile's is_premium boolean.
const { data: subscriptionsData } = await adminData
.from('subscriptions')
.select('id, store, product_id, status, entitlement_ids, purchased_at, expires_at, revenuecat_app_user_id')
.eq('user_id', userId)
.order('purchased_at', { ascending: false });
setSubscriptions((subscriptionsData || []) as Subscription[]);
// Fetch notification preferences (read-only visibility for support).
const { data: notificationPrefsData } = await adminData
.from('notification_preferences')
.select('*')
.eq('user_id', userId)
.maybeSingle();
setNotificationPrefs(notificationPrefsData);
// Fetch recent dares sent by or to this user (sent_dares has no OR support
// in the admin query builder, so this issues two filtered queries and merges).
const [sentByUser, sentToUser] = await Promise.all([
adminData.from('sent_dares')
.select('id, dare_text_snapshot, custom_dare_text, sender_id, recipient_id, status, sent_at, expires_at')
.eq('sender_id', userId).order('sent_at', { ascending: false }).limit(10),
adminData.from('sent_dares')
.select('id, dare_text_snapshot, custom_dare_text, sender_id, recipient_id, status, sent_at, expires_at')
.eq('recipient_id', userId).order('sent_at', { ascending: false }).limit(10),
]);
const daresById = new Map<string, SentDare>();
for (const row of [...(sentByUser.data || []), ...(sentToUser.data || [])] as SentDare[]) {
daresById.set(row.id, row);
}
setSentDares([...daresById.values()].sort((a, b) => new Date(b.sent_at).getTime() - new Date(a.sent_at).getTime()).slice(0, 10));
if (profileData?.couple_id) {
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
setPartner(partnerData);
@@ -503,9 +531,20 @@ export function UserDetailPage() {
.eq('couple_id', profileData.couple_id)
.maybeSingle();
setStreak(streakData);
// Fetch recent quiz sessions for the couple (quiz_sessions has no user_id
// column; sessions belong to the couple both partners share).
const { data: quizSessionsData } = await adminData
.from('quiz_sessions')
.select('id, status, score_percent, completed_at, created_at')
.eq('couple_id', profileData.couple_id)
.order('created_at', { ascending: false })
.limit(10);
setQuizSessions((quizSessionsData || []) as QuizSession[]);
} else {
setPartner(null);
setStreak(null);
setQuizSessions([]);
}
const responsesFrom = (responsesPage - 1) * responsesPageSize;
@@ -789,12 +828,59 @@ export function UserDetailPage() {
</div>
</div>
</div></CardContent></Card>
{/* Subscriptions */}
{subscriptions.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
Subscriptions
</CardTitle>
<CardDescription>Purchase history from the store, independent of the is_premium flag</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead>Product</TableHead>
<TableHead>Status</TableHead>
<TableHead>Entitlements</TableHead>
<TableHead>Purchased</TableHead>
<TableHead>Expires</TableHead>
<TableHead>RevenueCat User</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((subscription) => (
<TableRow key={subscription.id}>
<TableCell className="capitalize">{subscription.store.toLowerCase().replace('_', ' ')}</TableCell>
<TableCell className="font-mono text-xs">{subscription.product_id}</TableCell>
<TableCell>
<Badge variant={subscription.status === 'active' ? 'default' : subscription.status === 'billing_issue' ? 'destructive' : 'secondary'} className="capitalize">
{subscription.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{subscription.entitlement_ids.join(', ') || '-'}</TableCell>
<TableCell className="text-sm">{format(new Date(subscription.purchased_at), 'MMM d, yyyy')}</TableCell>
<TableCell className="text-sm">{subscription.expires_at ? format(new Date(subscription.expires_at), 'MMM d, yyyy') : 'Never'}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{subscription.revenuecat_app_user_id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Onboarding Details */}
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.hide_nsfw ? 'bg-gray-100 dark:bg-gray-800' : 'bg-amber-100 dark:bg-amber-900'}`}>{profile.hide_nsfw ? <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" /> : <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Hide NSFW</p><p className="font-medium">{profile.hide_nsfw === null ? 'Not set' : profile.hide_nsfw ? 'Yes' : 'No'}</p></div></div>
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-teal-100 dark:bg-teal-900"><Globe className="h-4 w-4 text-teal-600 dark:text-teal-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Timezone</p><p className="font-medium">{profile.timezone || 'Not reported (UTC fallback)'}</p></div></div>
</div></CardContent></Card>
{/* Streak Info */}
{profile.couple_id && streak && (
@@ -835,6 +921,16 @@ export function UserDetailPage() {
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900">
<BadgeCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Last Completed (both partners)</p>
<p className="font-medium">{streak.last_completed_date ? format(new Date(streak.last_completed_date), 'MMM d, yyyy') : 'Never'}</p>
<p className="text-xs text-muted-foreground">Determines whether the streak is alive today or tomorrow</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
@@ -848,6 +944,28 @@ export function UserDetailPage() {
</CardContent>
</Card>
)}
{/* Notification Preferences */}
{notificationPrefs && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Notification Preferences
</CardTitle>
<CardDescription>Read-only. Configured by the user in the mobile app</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(Object.keys(notificationPreferenceLabels) as Array<keyof NotificationPreferences>).map((key) => (
<Badge key={key} variant={notificationPrefs[key] ? 'default' : 'secondary'} className="gap-1">
{notificationPrefs[key] ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{notificationPreferenceLabels[key]}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Feature Opt-Ins */}
{featureInterests.length > 0 && (
<Card>
@@ -872,6 +990,83 @@ export function UserDetailPage() {
</CardContent>
</Card>
)}
{/* Quiz Sessions */}
{quizSessions.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-primary" />
Couples Quiz Sessions
</CardTitle>
<CardDescription>Recent quiz sessions for this couple</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
<TableHead>Started</TableHead>
<TableHead>Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{quizSessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<Badge variant={session.status === 'completed' ? 'default' : 'secondary'} className="capitalize">{session.status}</Badge>
</TableCell>
<TableCell>{session.score_percent !== null ? `${session.score_percent}%` : '-'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{format(new Date(session.created_at), 'MMM d, yyyy')}</TableCell>
<TableCell className="text-sm text-muted-foreground">{session.completed_at ? format(new Date(session.completed_at), 'MMM d, yyyy') : '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Recent Dares */}
{sentDares.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Swords className="h-5 w-5 text-primary" />
Recent Dares
</CardTitle>
<CardDescription>Dares this user sent or received. See the Sent Dares page for the full loop and messages</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Dare</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Status</TableHead>
<TableHead>Sent</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sentDares.map((dare) => {
const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus];
return (
<TableRow key={dare.id}>
<TableCell className="max-w-md line-clamp-2">{dare.custom_dare_text || dare.dare_text_snapshot}</TableCell>
<TableCell className="text-sm text-muted-foreground">{dare.sender_id === userId ? 'Sent' : 'Received'}</TableCell>
<TableCell><Badge variant={statusInfo?.variant ?? 'secondary'} className="capitalize">{statusInfo?.label ?? dare.status}</Badge></TableCell>
<TableCell className="text-sm text-muted-foreground">{format(new Date(dare.sent_at), 'MMM d, yyyy')}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Tabs */}
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList>
+8
View File
@@ -25,6 +25,14 @@ export default defineConfig({
host: true,
port: 3001,
allowedHosts: ['manage.sauci.app'],
// The API has no CORS handling, so local development serves API calls
// same-origin and forwards them to the local API process.
proxy: {
'/v1': {
target: process.env.SAUCI_ADMIN_API_PROXY ?? 'http://127.0.0.1:3003',
changeOrigin: true,
},
},
watch: {
usePolling: true,
interval: 100,
@@ -0,0 +1,25 @@
-- Unpaired users previously hit a waiting room with no way to answer questions,
-- because responses.couple_id was NOT NULL. This migration relaxes that column so
-- a solo user can bank "sealed" answers before they have a couple. The foreign key
-- and ON DELETE CASCADE are unchanged for non-null values: once a couple is deleted,
-- the responses that belonged to it are still removed with it. Sealed answers keep
-- couple_id NULL until claimed by the application at pairing time, so couple deletion
-- has no effect on them; they are not tied to any couple's lifecycle until claimed.
ALTER TABLE "responses" ALTER COLUMN "couple_id" DROP NOT NULL;
--> statement-breakpoint
-- A sealed answer (couple_id IS NULL) has no couple to digest a match notification
-- for yet; the partner digest only makes sense once claim-on-pair assigns the
-- response to a couple, at which point the claim path computes matches directly.
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
IF NEW.couple_id IS NULL THEN RETURN NEW; END IF;
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
ON CONFLICT(couple_id) DO UPDATE SET
active_user_id=excluded.active_user_id,
response_count=pending_match_notifications.response_count+1,
notify_at=excluded.notify_at,
updated_at=now();
RETURN NEW;
END $$;
+15 -1
View File
@@ -148,6 +148,20 @@
"when": 1788168060000,
"tag": "0020_quiz_question_pool",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1788168090000,
"tag": "0021_dare_proof",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1788168150000,
"tag": "0022_solo_sealed_answers",
"breakpoints": true
}
]
}
}
+11
View File
@@ -1,5 +1,6 @@
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { z } from 'zod';
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
import type { ApiRepository } from './db/repository.js';
@@ -36,6 +37,7 @@ type Variables = { identity: AuthIdentity };
export interface AppDependencies {
auth: AuthVerifier;
repository: ApiRepository;
corsAllowedOrigins?: string[];
coupleService?: CoupleService;
packsRepository?: PacksRepository;
answersRepository?: AnswersRepository;
@@ -61,6 +63,15 @@ function error(code: string, message: string): ApiErrorResponse {
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
const app = new Hono<{ Variables: Variables }>();
const corsAllowedOrigins = new Set(deps.corsAllowedOrigins ?? ['https://sauci.app']);
// The public web redemption form calls the API from a distinct browser origin.
// API authentication remains bearer-token based, so this does not grant cross-origin access to protected data.
app.use('*', cors({
origin: (origin) => corsAllowedOrigins.has(origin) ? origin : null,
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
}));
app.use('*', async (c, next) => {
const startedAt = performance.now();
try {
+22
View File
@@ -2,10 +2,30 @@ import { z } from 'zod';
const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional());
const optionalUuid = z.preprocess((value) => value === '' ? undefined : value, z.string().uuid().optional());
const corsOrigins = z.string().default('https://sauci.app').transform((value, ctx) => {
const origins = value.split(',').map((origin) => origin.trim()).filter(Boolean);
if (origins.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'CORS_ALLOWED_ORIGINS must contain at least one origin' });
return z.NEVER;
}
for (const origin of origins) {
try {
const parsed = new URL(origin);
if (parsed.origin !== origin || parsed.username || parsed.password || parsed.pathname !== '/') {
throw new Error('not an origin');
}
} catch {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid CORS origin: ${origin}` });
return z.NEVER;
}
}
return origins;
});
const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
CORS_ALLOWED_ORIGINS: corsOrigins,
DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
@@ -30,6 +50,7 @@ const baseSchema = z.object({
export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
corsAllowedOrigins: string[];
databaseUrl: string;
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
authIssuer: string;
@@ -84,6 +105,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return {
nodeEnv: parsed.NODE_ENV,
port: parsed.PORT,
corsAllowedOrigins: parsed.CORS_ALLOWED_ORIGINS,
databaseUrl: parsed.DATABASE_URL,
databasePool: {
max: parsed.DATABASE_POOL_MAX,
+1
View File
@@ -32,6 +32,7 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
is_premium: row.isPremium,
couple_id: row.coupleId,
gender: row.gender,
usage_reason: row.usageReason,
show_explicit_content: row.showExplicitContent,
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
created_at: row.createdAt.toISOString(),
+3
View File
@@ -10,6 +10,9 @@ export const profiles = pgTable('profiles', {
isPremium: boolean('is_premium').notNull().default(false),
coupleId: uuid('couple_id'),
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
usageReason: text('usage_reason').$type<
'improve_communication' | 'reconnect' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null
>(),
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
maxIntensity: integer('max_intensity').notNull().default(5),
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
+6
View File
@@ -35,6 +35,12 @@ const resources: Record<string, ResourceRule> = {
media_objects: { read: 'view_media' },
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
dares: { read: 'manage_questions', write: 'manage_questions' },
quiz_questions: { read: ['manage_questions', 'view_activity', 'view_users'], write: 'manage_questions' },
quiz_sessions: { read: ['view_users', 'view_activity'] },
quiz_answers: { read: ['view_users', 'view_activity'] },
sent_dares: { read: ['view_users', 'view_activity', 'view_chats'] },
dare_messages: { read: ['view_chats', 'view_activity'] },
notification_preferences: { read: 'view_users', idColumn: 'user_id' },
};
function identifier(value: string): string {
+24 -7
View File
@@ -42,7 +42,7 @@ export interface AnswersRepository {
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null; sealed_count?: number }>;
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
@@ -58,10 +58,17 @@ export class PostgresAnswersRepository implements AnswersRepository {
private zones: Promise<Set<string>> | null = null;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
/**
* couple_id is nullable here: an unpaired user still has a profile and can answer
* questions solo, banking them as sealed answers (couple_id IS NULL on responses)
* until a partner joins. Callers that need a couple for their own operation (e.g.
* matches, streaks) get an empty/no-op result naturally because every downstream
* query is scoped by couple_id, which stays null for a solo caller.
*/
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
const row = result.rows[0];
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
if (!row) throw new AnswersError('no_couple', 409, 'Complete signup before answering questions');
return row;
}
@@ -147,15 +154,25 @@ export class PostgresAnswersRepository implements AnswersRepository {
await this.assertEligible(client,userId,ctx,q);
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
if (!existing.rowCount) {
const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]);
// A solo answerer has no partner, so the catch-up exemption never applies:
// every sealed answer counts against the daily limit like a fresh one.
const catchingUp = ctx.couple_id
? await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId])
: { rowCount: 0 };
if (!catchingUp.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at });
}
}
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
await this.touchStreak(client,userId,ctx.couple_id!);
if (!ctx.couple_id) {
// No couple yet: bank the answer as sealed. It is claimed and reconciled
// into matches once a partner joins; see couples repository join().
const sealed = await client.query<{ count: number }>('select count(*)::int count from responses where user_id=$1 and couple_id is null',[userId]);
return { response: this.dates(response.rows[0]), match: null, sealed_count: sealed.rows[0]?.count ?? 0 };
}
const match = await this.reconcileMatch(client,userId,ctx.couple_id,q,input.answer,input.responseData ?? null,true);
await this.touchStreak(client,userId,ctx.couple_id);
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
});
}
@@ -172,9 +189,9 @@ export class PostgresAnswersRepository implements AnswersRepository {
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
}
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
const before=existing.rows[0]; const match=ctx.couple_id?await this.reconcileMatch(client,userId,ctx.couple_id,q,input.answer,normalized,false):null;
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
await this.touchStreak(client,userId,ctx.couple_id!);
if (ctx.couple_id) await this.touchStreak(client,userId,ctx.couple_id);
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
});
}
+68 -2
View File
@@ -1,6 +1,7 @@
import type { Couple, Profile } from '@sauci/shared';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { calculateMatchType, type Answer } from '../answers/types.js';
import { CoupleError, type CoupleStateResponse } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -77,6 +78,60 @@ async function profileForUpdate(client: PoolClient, userId: string): Promise<{ c
return profile;
}
/**
* A user answering solo banks each answer with couple_id NULL ("sealed"). Once
* pairing reaches two members, every sealed answer belonging to either member is
* claimed into the couple, and any question both members now have an answer for
* gets its match computed and upserted, reusing the same match-type rules the
* live answer flow uses. This only runs from join(), the one place membership can
* go from one to two, so it only ever fires once per couple.
*/
async function claimSealedAnswers(client: PoolClient, coupleId: string): Promise<void> {
await client.query(
`update responses set couple_id = $1
where couple_id is null and user_id in (select id from profiles where couple_id = $1)`,
[coupleId],
);
const pairs = await client.query<{
question_id: string;
question_type: string;
a_user: string;
a_answer: Answer;
a_data: Record<string, unknown> | null;
b_user: string;
b_answer: Answer;
b_data: Record<string, unknown> | null;
}>(
`select r1.question_id, q.question_type,
r1.user_id a_user, r1.answer a_answer, r1.response_data a_data,
r2.user_id b_user, r2.answer b_answer, r2.response_data b_data
from responses r1
join responses r2 on r2.question_id = r1.question_id and r2.couple_id = r1.couple_id and r2.user_id > r1.user_id
join questions q on q.id = r1.question_id
where r1.couple_id = $1`,
[coupleId],
);
for (const pair of pairs.rows) {
const matchType = calculateMatchType({ id: pair.question_id, question_type: pair.question_type as never }, pair.a_answer, pair.b_answer);
if (!matchType) {
await client.query('delete from matches where couple_id = $1 and question_id = $2', [coupleId, pair.question_id]);
continue;
}
const responseSummary = matchType === 'both_answered'
? { [pair.a_user]: pair.a_data, [pair.b_user]: pair.b_data }
: null;
await client.query(
`insert into matches(couple_id, question_id, match_type, is_new, response_summary)
values($1, $2, $3, true, $4)
on conflict(couple_id, question_id) do update set
match_type = excluded.match_type, response_summary = excluded.response_summary, is_new = true`,
[coupleId, pair.question_id, matchType, responseSummary],
);
}
}
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
@@ -111,7 +166,8 @@ export class PostgresCoupleRepository implements CoupleRepository {
if (!profile) {
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
}
if (!profile.couple_id) return { couple: null, partner: null };
const sealedCount = await this.sealedCount(userId);
if (!profile.couple_id) return { couple: null, partner: null, sealed_count: sealedCount };
const [coupleResult, partnerResult] = await Promise.all([
this.pool.query<CoupleRecord>(
@@ -129,13 +185,22 @@ export class PostgresCoupleRepository implements CoupleRepository {
),
]);
const couple = coupleResult.rows[0];
if (!couple) return { couple: null, partner: null };
if (!couple) return { couple: null, partner: null, sealed_count: sealedCount };
return {
couple: toCouple(couple),
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
sealed_count: sealedCount,
};
}
private async sealedCount(userId: string): Promise<number> {
const result = await this.pool.query<{ count: number }>(
'select count(*)::int count from responses where user_id = $1 and couple_id is null',
[userId],
);
return result.rows[0]?.count ?? 0;
}
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
return transaction(this.pool, async (client) => {
const profile = await profileForUpdate(client, userId);
@@ -183,6 +248,7 @@ export class PostgresCoupleRepository implements CoupleRepository {
}
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [couple.id, userId]);
await claimSealedAnswers(client, couple.id);
return toCouple(couple);
});
}
+2
View File
@@ -3,6 +3,8 @@ import type { Couple, Profile } from '@sauci/shared';
export interface CoupleStateResponse {
couple: Couple | null;
partner: Profile | null;
/** Count of this user's banked answers with no couple yet (couple_id IS NULL). */
sealed_count: number;
}
export interface CoupleMutationResponse {
+10 -4
View File
@@ -132,9 +132,14 @@ export class PostgresOperationsRepository implements OperationsRepository {
and exists(select 1 from responses r where r.couple_id=p.couple_id and r.created_at>$1::timestamptz-interval '7 days')
group by p.id on conflict(dedupe_key) do nothing`, [now]);
// Sealed answers are the earned-value hook for the nudge: a solo answerer who
// has already banked answers gets told what pairing unlocks for them, instead
// of the generic invite copy that applies before they have answered anything.
const unpaired=await client.query(
`with eligible as (
select p.id,p.couple_id is not null has_invite from profiles p left join notification_preferences np on np.user_id=p.id
select p.id,p.couple_id is not null has_invite,
(select count(*)::int from responses r where r.user_id=p.id and r.couple_id is null) sealed_count
from profiles p left join notification_preferences np on np.user_id=p.id
where extract(hour from $1::timestamptz)>=18 and p.push_token is not null
and (p.couple_id is null or (select count(*) from profiles member where member.couple_id=p.couple_id)<2)
and coalesce(np.unpaired_reminders_enabled,true)
@@ -143,10 +148,11 @@ export class PostgresOperationsRepository implements OperationsRepository {
), queued as (
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
jsonb_build_object('title','Sauci','body',case when e.has_invite
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
jsonb_build_object('title','Sauci','body',case
when e.sealed_count>0 then 'You have '||e.sealed_count||' sealed answer'||(case when e.sealed_count=1 then '' else 's' end)||' waiting. Invite your partner to unlock what you said about them.'
when e.has_invite then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
else 'Ready to get started? Create an invite code and share it with your partner!' end,
'data',jsonb_build_object('type','unpaired_reminder'))
'data',jsonb_build_object('type','unpaired_reminder','sealed_count',e.sealed_count))
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
+5 -1
View File
@@ -35,6 +35,8 @@ export const questionPacks = pgTable('question_packs', {
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
scheduledReleaseAt: timestamp('scheduled_release_at', { withTimezone: true }),
releaseNotified: boolean('release_notified').notNull().default(false),
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
export const questions = pgTable('questions', {
@@ -61,7 +63,9 @@ export const responses = pgTable('responses', {
id: uuid('id').primaryKey(),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }),
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
// Nullable: an unpaired user's answer is banked as a sealed answer with no couple
// yet. It is claimed into the couple at pairing time; see the answers repository.
coupleId: uuid('couple_id').references(() => couples.id, { onDelete: 'cascade' }),
answer: text('answer').notNull(),
responseData: jsonb('response_data'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+1
View File
@@ -65,6 +65,7 @@ const adminRepository = new PostgresAdminRepository(pool, {
const app = createApp({
auth: authVerifier,
repository,
corsAllowedOrigins: config.corsAllowedOrigins,
coupleService: new CoupleService(coupleRepository),
packsRepository,
answersRepository,
+51 -1
View File
@@ -21,13 +21,14 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi
const responseQuestion = '88888888-8888-4888-8888-888888888888';
const responseId = '99999999-9999-4999-8999-999999999999';
const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const quizCouple = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
let pool: Pool; let repository: PostgresAdminRepository;
beforeAll(async () => {
await adminPool.query(`create schema "${schema}"`);
const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() }); repository = new PostgresAdminRepository(isolated.toString());
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql']) {
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql','0011_question_pack_average_precision.sql','0012_catalogue_field_parity.sql','0013_dares_loop.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql','0018_cutover_source_parity.sql','0019_quiz.sql','0020_quiz_question_pool.sql']) {
const migration = await readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8');
for (const statement of migration.split('--> statement-breakpoint')) if (statement.trim()) await pool.query(statement);
}
@@ -161,6 +162,55 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi
await expect(repository.responseMedia(responseViewer, responseId)).rejects.toMatchObject({ code: 'response_media_not_found' });
});
it('exposes writable quiz content and read-only quiz activity resources', async () => {
await pool.query('insert into couples(id,invite_code) values($1,$2)', [quizCouple, 'QUIZADMN']);
await pool.query("update admin_users set permissions=array_append(permissions,'manage_questions') where user_id=$1", [creatorUser]);
const creator = await repository.principal(creatorUser);
const [question] = await repository.insert(creator, 'quiz_questions', [{
prompt_self: 'How do you show affection?', prompt_guess: 'How does your partner show affection?', options: ['Touch', 'Words'],
}]);
expect(question).toMatchObject({ prompt_self: 'How do you show affection?' });
const principal = await repository.principal(superUser);
const listed = await repository.query(principal, 'quiz_questions', { filters: [{ column: 'id', op: 'eq', value: question!.id }] });
expect(listed.count).toBe(1);
expect((await repository.query(principal, 'quiz_sessions', {})).count).toBe(0);
await expect(repository.insert(principal, 'quiz_sessions', [{
couple_id: quizCouple, status: 'active', question_ids: [question!.id],
}])).rejects.toMatchObject({ code: 'forbidden' });
});
it('reads sent dares for user support visibility without granting write access', async () => {
await pool.query(
`insert into sent_dares(couple_id,custom_dare_text,dare_text_snapshot,dare_intensity_snapshot,sender_id,recipient_id,status)
values($1,'Give a compliment','Give a compliment',2,$2,$3,'pending')`,
[quizCouple, creatorUser, moderatorUser],
);
const principal = await repository.principal(superUser);
const result = await repository.query(principal, 'sent_dares', {});
expect(result.count).toBe(1);
expect(result.rows[0]).toMatchObject({ dare_text_snapshot: 'Give a compliment' });
await expect(repository.insert(principal, 'sent_dares', [{
couple_id: quizCouple, custom_dare_text: 'x', dare_text_snapshot: 'x',
dare_intensity_snapshot: 1, sender_id: creatorUser, recipient_id: moderatorUser, status: 'pending',
}])).rejects.toMatchObject({ code: 'forbidden' });
});
it('reads notification preferences for user support visibility without granting write access', async () => {
await pool.query(
"insert into notification_preferences(user_id,matches_enabled,dares_enabled) values($1,true,false)",
[creatorUser],
);
const principal = await repository.principal(superUser);
const result = await repository.query(principal, 'notification_preferences', {
filters: [{ column: 'user_id', op: 'eq', value: creatorUser }],
});
expect(result).toMatchObject({ count: 1, rows: [expect.objectContaining({ matches_enabled: true, dares_enabled: false })] });
await expect(repository.insert(principal, 'notification_preferences', [{
user_id: moderatorUser,
}])).rejects.toMatchObject({ code: 'forbidden' });
});
it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => {
const tables = await pool.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema=current_schema()
+23 -2
View File
@@ -5,8 +5,8 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc',solo='dddddddd-dddd-4ddd-8ddd-dddddddddddd',soloQ1='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',soloQ2='ffffffff-ffff-4fff-8fff-ffffffffffff';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql','0022_solo_sealed_answers.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into profiles(id,gender,max_intensity) values($1,null,5)",[solo]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null),($8,$2,'Solo one',2,null),($9,$2,'Solo two',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ,soloQ1,soloQ2]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
@@ -109,4 +109,25 @@ it('buckets the window on the user local day and falls back to UTC',async()=>{aw
const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected");
for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}});
it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);});
describe('solo answering before a couple exists',()=>{
it('recommends questions to an unpaired user instead of a no_couple error',async()=>{
const questions=await repo.recommended(solo);
expect(questions.map(x=>x.id)).toContain(soloQ1);
});
it('banks a solo answer with no couple, skips match reconciliation, and reports the sealed count',async()=>{
const first=await repo.submit(solo,{questionId:soloQ1,answer:'yes'});
expect(first.match).toBeNull();
expect(first.sealed_count).toBe(1);
const stored=await pool.query('select couple_id from responses where user_id=$1 and question_id=$2',[solo,soloQ1]);
expect(stored.rows[0].couple_id).toBeNull();
const second=await repo.submit(solo,{questionId:soloQ2,answer:'maybe'});
expect(second.match).toBeNull();
expect(second.sealed_count).toBe(2);
});
it('lets a solo answerer edit a sealed answer without a couple to reconcile against',async()=>{
await expect(repo.update(solo,{questionId:soloQ1,answer:'no',responseData:undefined,confirmDeleteMatch:false})).resolves.toMatchObject({success:true});
});
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresRepository(isolatedUrl.toString());
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql']
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0005_profile_settings.sql','0014_daily_limit_local_reset.sql']
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
+29
View File
@@ -81,6 +81,35 @@ describe('Sauci API', () => {
expect(repository.ready).toHaveBeenCalledOnce();
});
it('allows browser preflight requests only from configured web origins', async () => {
const app = createApp({
auth: verifier(),
repository: new MemoryRepository(),
corsAllowedOrigins: ['http://127.0.0.1:3010', 'https://sauci.app'],
});
const response = await app.request('/public/v1/redemptions', {
method: 'OPTIONS',
headers: {
origin: 'http://127.0.0.1:3010',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
});
expect(response.status).toBe(204);
expect(response.headers.get('access-control-allow-origin')).toBe('http://127.0.0.1:3010');
expect(response.headers.get('access-control-allow-methods')).toContain('POST');
const rejected = await app.request('/public/v1/redemptions', {
method: 'OPTIONS',
headers: {
origin: 'https://unapproved.example',
'access-control-request-method': 'POST',
},
});
expect(rejected.headers.get('access-control-allow-origin')).toBeNull();
});
it('fails closed when the bearer token is missing or invalid', async () => {
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
expect((await app.request('/v1/me')).status).toBe(401);
@@ -26,7 +26,13 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresCoupleRepository(isolatedUrl.toString());
for (const migrationName of ['0000_identity_and_feature_interests.sql', '0001_couples.sql']) {
for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0003_answers_matches.sql',
'0022_solo_sealed_answers.sql',
]) {
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
@@ -57,8 +63,71 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
await expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' });
await repository.cancel(alice);
await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null });
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null });
await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
});
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const question = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
await pool.query(`insert into profiles(id, name) values ($1, 'Solo')`, [solo]);
await pool.query(`insert into question_packs(id, name) values ($1, 'Pack')`, [pack]);
await pool.query(`insert into questions(id, pack_id, text) values ($1, $2, 'Question')`, [question, pack]);
await pool.query(
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')`,
[randomUUID(), solo, question],
);
await expect(repository.getState(solo)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
});
it('claims both members\' sealed answers and computes matches when the second member joins', async () => {
const coupleId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
const dana = '44444444-4444-4444-8444-444444444444';
const erin = '55555555-5555-4555-8555-555555555555';
const pack = '66666666-6666-4666-8666-666666666666';
const yesYesQuestion = '77777777-7777-4777-8777-777777777777';
const yesNoQuestion = '88888888-8888-4888-8888-888888888888';
const soloOnlyQuestion = '99999999-9999-4999-8999-999999999999';
await pool.query(`insert into profiles(id, name) values ($1, 'Dana'), ($2, 'Erin')`, [dana, erin]);
await pool.query(`insert into question_packs(id, name) values ($1, 'Claim pack')`, [pack]);
await pool.query(
`insert into questions(id, pack_id, text) values ($1, $2, 'Yes yes'), ($3, $2, 'Yes no'), ($4, $2, 'Solo only')`,
[yesYesQuestion, pack, yesNoQuestion, soloOnlyQuestion],
);
// Both answer the same question before pairing (sealed, couple_id null). Dana
// and Erin agree on one question (yes/yes), disagree on another (yes/no), and
// Dana alone answers a third, which has no match to compute.
const insertResponse = (userId: string, questionId: string, answer: 'yes' | 'no' | 'maybe') =>
pool.query(
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, $4)`,
[randomUUID(), userId, questionId, answer],
);
await insertResponse(dana, yesYesQuestion, 'yes');
await insertResponse(erin, yesYesQuestion, 'yes');
await insertResponse(dana, yesNoQuestion, 'yes');
await insertResponse(erin, yesNoQuestion, 'no');
await insertResponse(dana, soloOnlyQuestion, 'maybe');
await repository.create(dana, coupleId, 'CLAIM123');
await repository.join(erin, 'CLAIM123');
const claimed = await pool.query<{ couple_id: string | null; count: number }>(
'select couple_id, count(*)::int count from responses where user_id in ($1,$2) group by couple_id',
[dana, erin],
);
expect(claimed.rows).toEqual([{ couple_id: coupleId, count: 5 }]);
// Yes/yes produces a match; yes/no produces none; the solo-only answer has no
// partner response yet, so only one match is computed from the claim.
const matches = await pool.query<{ question_id: string; match_type: string }>(
'select question_id, match_type from matches where couple_id=$1 order by question_id',
[coupleId],
);
expect(matches.rows).toEqual([{ question_id: yesYesQuestion, match_type: 'yes_yes' }]);
});
});
+2 -2
View File
@@ -14,7 +14,7 @@ const identity: AuthIdentity = {
function setup() {
const repository: CoupleRepository = {
getState: vi.fn(async () => ({ couple: null, partner: null })),
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async (_userId, coupleId, inviteCode) => ({
id: coupleId,
invite_code: inviteCode,
@@ -43,7 +43,7 @@ describe('couple routes', () => {
const response = await app.request('/v1/couple');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ couple: null, partner: null });
expect(await response.json()).toEqual({ couple: null, partner: null, sealed_count: 0 });
expect(repository.getState).toHaveBeenCalledWith(identity.id);
});
+1 -1
View File
@@ -12,7 +12,7 @@ const couple: Couple = {
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
return {
getState: vi.fn(async () => ({ couple: null, partner: null })),
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async () => couple),
join: vi.fn(async () => couple),
cancel: vi.fn(async () => undefined),
+17 -1
View File
@@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
beforeAll(async()=>{
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql','0022_solo_sealed_answers.sql']){
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
}
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
@@ -59,6 +59,22 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key=$1',[`discord:couple_paired:${inviteCouple}`])).rows[0].count).toBe(1);
});
it('escalates the unpaired nudge copy with the sealed answer count',async()=>{
const solo=randomUUID(),secondQuestion=randomUUID();
await pool.query(`insert into profiles(id,name,email,push_token,onboarding_completed) values($1,'Solo','solo@test','token-solo',false)`,[solo]);
await pool.query('insert into questions(id,pack_id,text) values($1,$2,$3)',[secondQuestion,pack,'Second question']);
await pool.query(`insert into responses(id,user_id,question_id,couple_id,answer) values($1,$2,$3,null,'yes'),($4,$2,$5,null,'maybe')`,
[randomUUID(),solo,question,randomUUID(),secondQuestion]);
await repo.produce(new Date('2026-08-28T18:00:00.000Z'),100);
const body=(await pool.query<{body:string;data:{sealed_count:number}}>(
`select payload->>'body' body,payload->'data' data from operations_outbox where dedupe_key=$1`,
[`unpaired:2026-08-28:${solo}`],
)).rows[0];
expect(body?.body).toContain('2 sealed answers waiting');
expect(body?.data).toMatchObject({sealed_count:2});
});
it('atomically releases packs, celebrates milestones, and produces a single digest',async()=>{
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,streak_celebrated_at) values($1,7,7,0) on conflict(couple_id) do update set current_streak=7,streak_celebrated_at=0`,[couple]);
const result=await repo.produce(new Date(Date.now()+10*60*1000),100);
+16 -10
View File
@@ -13,13 +13,19 @@ EXPO_PUBLIC_API_URL=http://127.0.0.1:3003
# invoking shell. Do not put those credentials in this Expo environment file or
# commit them. The test user must be disposable, onboarding-complete, and have a
# matching profile in the standalone non-production PostgreSQL database.
# Sentry error tracking (optional - get DSN from https://sentry.io)
EXPO_PUBLIC_SENTRY_DSN=
# RevenueCat (for in-app subscriptions)
# Get your API keys from https://app.revenuecat.com
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set)
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
# Facebook App ID for Instagram Stories sharing (optional). Instagram requires
# a registered Facebook App ID for the Stories share flow; without it the
# Instagram share button falls back to the system share sheet.
# https://developers.facebook.com/docs/instagram/sharing-to-stories/
EXPO_PUBLIC_FACEBOOK_APP_ID=
# Sentry error tracking (optional - get DSN from https://sentry.io)
EXPO_PUBLIC_SENTRY_DSN=
# RevenueCat (for in-app subscriptions)
# Get your API keys from https://app.revenuecat.com
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set)
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
+52 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 48
versionName "1.0.6"
versionCode 49
versionName "1.0.7"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
@@ -182,3 +182,53 @@ dependencies {
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
// Reanimated 4.1.7 imports Worklets from its pre-AGP-8 CMake output path,
// while AGP 8.14 writes release outputs below intermediates/cxx/RelWithDebInfo.
// Keep the checked-in Android project compatible with the supported SDK 54
// dependency set by materializing that one legacy input after Worklets builds.
def workletsReleaseAbis = ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"]
def syncWorkletsReleaseCmakeInputs = tasks.register("syncWorkletsReleaseCmakeInputs") {
dependsOn(":react-native-worklets:externalNativeBuildRelease")
doLast {
def workletsIntermediates = file("${rootDir}/../../../node_modules/react-native-worklets/android/build/intermediates")
workletsReleaseAbis.each { abi ->
def libraries = fileTree(workletsIntermediates) {
include "cxx/RelWithDebInfo/**/obj/${abi}/libworklets.so"
}.files
if (libraries.size() != 1) {
throw new GradleException("Expected exactly one Worklets release library for ${abi}, found ${libraries.size()}")
}
def source = libraries.iterator().next()
def destination = file("${workletsIntermediates}/cmake/release/obj/${abi}/libworklets.so")
destination.parentFile.mkdirs()
java.nio.file.Files.deleteIfExists(destination.toPath())
source.withInputStream { input ->
destination.withOutputStream { output -> output << input }
}
if (destination.length() != source.length()) {
throw new GradleException("Failed to copy Worklets release library for ${abi}")
}
}
}
}
gradle.projectsEvaluated {
def reanimatedProject = findProject(":react-native-reanimated")
reanimatedProject?.tasks?.configureEach { task ->
if (task.name == "externalNativeBuildRelease" || task.name == "prefabReleasePackage") {
task.dependsOn(syncWorkletsReleaseCmakeInputs)
}
}
tasks.configureEach { task ->
if (task.name.startsWith("configureCMakeRelWithDebInfo")) {
task.dependsOn(
":react-native-worklets:prefabReleasePackage",
":react-native-reanimated:prefabReleasePackage",
":shopify_react-native-skia:prefabReleasePackage",
)
}
}
}
@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="com.android.vending.BILLING"/>
<queries>
<package android:name="com.instagram.android"/>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
@@ -26,9 +27,14 @@
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----&#xA;MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV&#xA;BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG&#xA;A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac&#xA;8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx&#xA;SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW&#xA;SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn&#xA;JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV&#xA;hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW&#xA;VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E&#xA;DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl&#xA;2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT&#xA;MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07&#xA;JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo&#xA;JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5&#xA;3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY&#xA;Ug==&#xA;-----END CERTIFICATE-----&#xA;"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;keyid&quot;:&quot;main&quot;,&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;}"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.apps.jbrunton.co.uk/manifest"/>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;production&quot;,&quot;expo-app-id&quot;:&quot;9a66f3c2-fda7-416f-87e2-6db833b32e9d&quot;,&quot;xprem-branch&quot;:&quot;&quot;}"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
@@ -43,6 +49,12 @@
<data android:scheme="com.sauci.app"/>
<data android:scheme="exp+sauci"/>
</intent-filter>
<intent-filter android:autoVerify="true" data-generated="true">
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https" android:host="sauci.app" android:pathPrefix="/join"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
<intent-filter>
@@ -52,4 +64,4 @@
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/>
</receiver>
</application>
</manifest>
</manifest>
@@ -3,4 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
<string name="expo_runtime_version">1.0.7</string>
</resources>
+106
View File
@@ -0,0 +1,106 @@
// Dynamic Expo config.
//
// `app.json` stays the static source of truth for everything that is stable
// across environments, including the `version`, `ios.buildNumber`, and
// `android.versionCode` fields that EAS rewrites during a release build. This
// file only layers the over-the-air update configuration on top, because the
// XPREM update server selects a release channel from a build-time request
// header and that value differs per EAS build profile.
//
// See `docs/mobile-ota.md`.
/* global __dirname */
const fs = require('fs');
const path = require('path');
const appJson = require('./app.json');
/** Base URL of the self-hosted XPREM update server. */
const OTA_SERVER_URL = 'https://ota.apps.jbrunton.co.uk';
/** XPREM manifest endpoint. XPREM serves the Expo Updates protocol here. */
const OTA_MANIFEST_URL = `${OTA_SERVER_URL}/manifest`;
/**
* XPREM application UUID for Sauci, shown in the XPREM dashboard under App
* Info. Not a secret; overridable for pointing a build at another app.
*/
const OTA_APP_ID = process.env.XPREM_APP_ID || '9a66f3c2-fda7-416f-87e2-6db833b32e9d';
/** Public code-signing certificate downloaded from the XPREM dashboard. */
const CODE_SIGNING_CERTIFICATE_PATH = './certs/certificate.pem';
/** Marker string present only in the committed placeholder certificate. */
const CERTIFICATE_PLACEHOLDER_MARKER = 'XPREM-PLACEHOLDER-CERTIFICATE';
/**
* Release channel bound to the build. XPREM maps a channel to a branch server
* side, so a channel can be repointed at a different branch without rebuilding.
* Set per EAS build profile in `eas.json` and exported before `eoas publish`.
*/
const releaseChannel = process.env.RELEASE_CHANNEL || 'development';
/**
* Code signing is skipped for local development, where the packager serves the
* bundle directly and no signed manifest exists:
* DISABLE_CODE_SIGNING=1 npx expo start --dev-client
*/
const codeSigningDisabled = Boolean(process.env.DISABLE_CODE_SIGNING);
if (releaseChannel === 'production' && codeSigningDisabled) {
throw new Error(
'RELEASE_CHANNEL=production does not permit DISABLE_CODE_SIGNING. ' +
'Production builds must embed the XPREM code-signing certificate and metadata.',
);
}
function assertUpdateConfigIsReal() {
const certificatePath = path.join(__dirname, CODE_SIGNING_CERTIFICATE_PATH);
const certificate = fs.existsSync(certificatePath)
? fs.readFileSync(certificatePath, 'utf8')
: '';
if (!certificate || certificate.includes(CERTIFICATE_PLACEHOLDER_MARKER)) {
throw new Error(
`RELEASE_CHANNEL=${releaseChannel} requires the real XPREM code-signing ` +
`certificate at apps/mobile/${CODE_SIGNING_CERTIFICATE_PATH}. ` +
'Download it from the XPREM dashboard App Info page. See docs/mobile-ota.md.',
);
}
}
// A build or publish always names its channel explicitly. Fail loudly there
// rather than shipping a build that can never validate a signed manifest.
if (process.env.RELEASE_CHANNEL) {
assertUpdateConfigIsReal();
}
module.exports = () => ({
...appJson.expo,
// Keep this explicit because EOAS validates a primitive runtime string before
// upload. The release version script updates it atomically with app.json and
// the checked-in native runtime values.
runtimeVersion: '1.0.7',
updates: {
enabled: true,
url: OTA_MANIFEST_URL,
checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 5000,
codeSigningCertificate: codeSigningDisabled
? undefined
: CODE_SIGNING_CERTIFICATE_PATH,
codeSigningMetadata: codeSigningDisabled
? undefined
: { keyid: 'main', alg: 'rsa-v1_5-sha256' },
// expo-updates only sends headers that were declared at build time, so
// every header XPREM may need has to be present here even when empty.
requestHeaders: {
'expo-channel-name': releaseChannel,
'expo-app-id': OTA_APP_ID,
// Branch surfing: lets a non-production build be pointed at another
// branch at runtime. Empty means the channel mapping decides.
'xprem-branch': '',
},
},
});
+16 -4
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Sauci",
"slug": "sauci",
"version": "1.0.6",
"version": "1.0.7",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "app.sauci",
@@ -27,7 +27,7 @@
"group.com.sauci.app"
]
},
"buildNumber": "45",
"buildNumber": "46",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": {
@@ -189,7 +189,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 48,
"versionCode": 49,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
@@ -298,7 +298,19 @@
}
],
"./plugins/withWidgetExported.js",
"./plugins/withNativeWidget.js"
"./plugins/withNativeWidget.js",
[
"react-native-share",
{
"ios": [
"instagram",
"instagram-stories"
],
"android": [
"com.instagram.android"
]
}
]
],
"experiments": {
"typedRoutes": true
+19
View File
@@ -13,6 +13,7 @@ import { StreakDisplay } from '../../src/components/StreakDisplay';
import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types';
import { pickRecommendedPacks, RECOMMENDED_PACKS_LABEL } from '../../src/features/home/recommendedPacks';
const MAX_CONTENT_WIDTH = 600;
@@ -112,6 +113,13 @@ export default function DiscoveryScreen() {
[packs, categories]
);
// A small, cheap personalization: highlight packs that match what the user
// said brought them to Sauci during onboarding, ahead of the full catalog.
const recommendedPacks = useMemo(
() => pickRecommendedPacks(packs, categories, user?.usage_reason ?? null),
[packs, categories, user?.usage_reason]
);
// Check if user has no packs enabled (empty state)
const hasNoPacks = packs.length === 0;
@@ -169,6 +177,17 @@ export default function DiscoveryScreen() {
{/* Quiz */}
<QuizTile delay={225} />
{/* Personalized pick based on the onboarding usage reason */}
{recommendedPacks.length > 0 && (
<ContentRow
title={RECOMMENDED_PACKS_LABEL}
packs={recommendedPacks}
isPremiumUser={isPremiumUser}
delay={240}
onShowPaywall={() => setShowPaywall(true)}
/>
)}
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
+42 -30
View File
@@ -27,14 +27,14 @@ import { GlassButton } from '../../src/components/ui/GlassButton';
import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
import { profileSettingsApi } from '../../src/lib/profileSettingsApi';
import { profileSettingsApi } from '../../src/lib/profileSettingsApi';
import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import type { Gender } from '../../src/types';
import type { Gender, UsageReason } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' },
@@ -43,17 +43,15 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
];
type UsageReason = 'improve_communication' | 'reconnect' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
{ value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' },
{ value: 'have_fun', label: 'Have fun together', icon: 'happy' },
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
];
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
export default function OnboardingScreen() {
const router = useRouter();
@@ -94,6 +92,13 @@ export default function OnboardingScreen() {
Events.onboardingStageComplete('avatar');
};
const handleAvatarSkip = () => {
setError(null);
setStage('name');
Events.avatarSkipped();
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => {
if (!name.trim()) {
setError('Please enter your name');
@@ -129,9 +134,9 @@ export default function OnboardingScreen() {
setError('Please select what brings you to Sauci');
return;
}
setStage('notifications');
Events.onboardingStageComplete('purpose');
};
setStage('notifications');
Events.onboardingStageComplete('purpose');
};
const handleEnableNotifications = async () => {
setIsLoading(true);
@@ -169,26 +174,26 @@ export default function OnboardingScreen() {
}
}
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue
// eligibility is enforced centrally by reviewed status.
max_intensity: 2 as const,
show_explicit_content: false,
hide_nsfw: true,
onboarding_completed: true,
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue
// eligibility is enforced centrally by reviewed status.
max_intensity: 2 as const,
show_explicit_content: false,
hide_nsfw: true,
onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION,
};
await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
};
await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser();
@@ -280,6 +285,13 @@ export default function OnboardingScreen() {
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
Continue
</GlassButton>
<Pressable
style={styles.skipButton}
onPress={handleAvatarSkip}
testID="avatar-skip-button"
>
<Text style={styles.skipText}>Skip for now</Text>
</Pressable>
</View>
</Animated.View>
);
@@ -569,7 +581,7 @@ export default function OnboardingScreen() {
style={styles.backButton}
onPress={() => {
setError(null);
if (stage === 'notifications') setStage('purpose');
if (stage === 'notifications') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
@@ -585,7 +597,7 @@ export default function OnboardingScreen() {
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View>
<View style={styles.backButtonPlaceholder} />
</Animated.View>
+32 -4
View File
@@ -16,9 +16,10 @@ import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import * as Clipboard from "expo-clipboard";
import { useAuthStore } from "../../src/store";
import { useAuthStore, useMatchStore } from "../../src/store";
import { coupleApi } from "../../src/lib/coupleApi";
import { getPairingError } from "../../src/lib/errors";
import { buildInviteShareMessage } from "../../src/lib/inviteShareCopy";
import { Events } from "../../src/lib/analytics";
import { router, useLocalSearchParams } from "expo-router";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
@@ -28,7 +29,7 @@ import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pend
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>();
const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -36,9 +37,13 @@ export default function PairingScreen() {
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false);
// Redirect if already paired
// Redirect if already paired. Pairing claims both members' sealed answers and
// computes matches server-side, so the matches store must refetch here too:
// whichever partner lands on this redirect first would otherwise show stale,
// pre-pairing matches until some unrelated screen happened to refresh it.
useEffect(() => {
if (couple && partner) {
void useMatchStore.getState().fetchMatches(true);
router.replace("/(app)");
}
}, [couple, partner]);
@@ -145,6 +150,9 @@ export default function PairingScreen() {
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
// Joining claims both members' sealed answers and computes matches
// server-side, so the joiner needs a fresh matches list too.
void useMatchStore.getState().fetchMatches(true);
Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [
@@ -171,7 +179,7 @@ export default function PairingScreen() {
};
const shareMessage = couple?.invite_code
? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)`
? buildInviteShareMessage(couple.invite_code, sealedCount)
: "";
const shareCode = async () => {
@@ -279,6 +287,21 @@ export default function PairingScreen() {
</Text>
</Animated.View>
{/* Earned value: sealed answers already banked while unpaired */}
{sealedCount > 0 && (
<Animated.View
entering={FadeInDown.delay(250).duration(500)}
style={styles.section}
>
<GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
They will unlock the moment your partner joins.
</Text>
</GlassCard>
</Animated.View>
)}
{/* Code Card */}
<Animated.View
entering={FadeInDown.delay(300).duration(500)}
@@ -645,6 +668,11 @@ const styles = StyleSheet.create({
textAlign: "center",
maxWidth: 280,
},
sealedCountText: {
...typography.body,
color: colors.text,
textAlign: "center",
},
section: {
marginBottom: spacing.md,
},
+14 -18
View File
@@ -115,17 +115,15 @@ export default function RootLayout() {
// Fetch user on mount
fetchUser();
// Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => {
console.log('[DeepLink] handleDeepLink called with URL:', url);
if (!url) return;
// Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => {
if (!url) return;
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
// are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
console.log('[DeepLink] Invite link detected:', inviteLink);
Events.inviteLinkOpened(inviteLink.source);
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState();
@@ -202,17 +200,15 @@ export default function RootLayout() {
}
};
// Get initial URL
Linking.getInitialURL().then((url) => {
console.log('[DeepLink] getInitialURL returned:', url);
handleDeepLink(url);
});
// Get initial URL
Linking.getInitialURL().then((url) => {
handleDeepLink(url);
});
// Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => {
console.log('[DeepLink] URL event listener triggered with:', event.url);
handleDeepLink(event.url);
});
// Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => {
handleDeepLink(event.url);
});
// Set up push notification response listener (for taps while app is running)
const notificationSubscription = setupNotificationResponseListener();
+6
View File
@@ -0,0 +1,6 @@
# The update-signing private key belongs to the XPREM server, never to the app.
private-key.pem
*.key
# The public certificate must be committed despite the root *.pem rule: the
# expo-updates plugin embeds it into every build.
!certificate.pem
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
+7 -2
View File
@@ -7,19 +7,24 @@
"development": {
"node": "20.19.4",
"developmentClient": true,
"distribution": "internal"
"distribution": "internal",
"env": {
"RELEASE_CHANNEL": "development"
}
},
"preview": {
"node": "20.19.4",
"distribution": "internal",
"env": {
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app",
"RELEASE_CHANNEL": "staging"
}
},
"production": {
"node": "20.19.4",
"autoIncrement": true,
"env": {
"RELEASE_CHANNEL": "production",
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
+38 -6
View File
@@ -417,7 +417,7 @@ PODS:
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- GoogleDataTransport (10.1.1):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleSignIn (9.1.0):
@@ -3236,6 +3236,34 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RNShare (12.3.1):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RNSVG (15.12.1):
- boost
- DoubleConversion
@@ -3517,6 +3545,7 @@ DEPENDENCIES:
- RNPurchases (from `../../../node_modules/react-native-purchases`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../../../node_modules/react-native-screens`)
- RNShare (from `../../../node_modules/react-native-share`)
- RNSVG (from `../../../node_modules/react-native-svg`)
- RNWorklets (from `../../../node_modules/react-native-worklets`)
- SocketRocket (~> 0.7.1)
@@ -3792,6 +3821,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-reanimated"
RNScreens:
:path: "../../../node_modules/react-native-screens"
RNShare:
:path: "../../../node_modules/react-native-share"
RNSVG:
:path: "../../../node_modules/react-native-svg"
RNWorklets:
@@ -3804,8 +3835,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d
@@ -3851,10 +3882,10 @@ SPEC CHECKSUMS:
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41
FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
@@ -3944,6 +3975,7 @@ SPEC CHECKSUMS:
RNPurchases: ae1f8f522674a7938dd26eba6c103c40d049dfee
RNReanimated: 45bfba06751010ae2f6a1cd8b9f618ac4ce1a12c
RNScreens: 2e9c41cd099b1ca50136af8d57c3594214d0086a
RNShare: 61cad1e65b8e3788c97ee7c5cac4bcbb25a8df40
RNSVG: 94a1be05fab4043354bcf7104f0f9b0e2231ef05
RNWorklets: 09a47bd92d3da6e00420a060fcc86e23b745e980
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
@@ -570,7 +570,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -599,7 +599,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -772,7 +772,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 46;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -782,7 +782,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
@@ -808,7 +808,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 46;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -818,7 +818,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
+115 -110
View File
@@ -1,113 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sauci</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>app.sauci</string>
<string>com.sauci.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+sauci</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>45</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
<key>NSFaceIDUsageDescription</key>
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Sauci uses your microphone to record audio when you capture videos to share privately with your partner.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sauci</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.7</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>app.sauci</string>
<string>com.sauci.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+sauci</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>46</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>instagram</string>
<string>instagram-stories</string>
</array>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
<key>NSFaceIDUsageDescription</key>
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Sauci uses your microphone to record audio when you capture videos to share privately with your partner.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
+5 -1
View File
@@ -8,9 +8,13 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:sauci.app</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.sauci.app</string>
</array>
</dict>
</plist>
</plist>
+42 -2
View File
@@ -4,9 +4,49 @@
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesCodeSigningCertificate</key>
<string>-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
</string>
<key>EXUpdatesCodeSigningMetadata</key>
<dict>
<key>keyid</key>
<string>main</string>
<key>alg</key>
<string>rsa-v1_5-sha256</string>
</dict>
<key>EXUpdatesEnabled</key>
<false/>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<integer>5000</integer>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>production</string>
<key>expo-app-id</key>
<string>9a66f3c2-fda7-416f-87e2-6db833b32e9d</string>
<key>xprem-branch</key>
<string/>
</dict>
<key>EXUpdatesRuntimeVersion</key>
<string>1.0.7</string>
<key>EXUpdatesURL</key>
<string>https://ota.apps.jbrunton.co.uk/manifest</string>
</dict>
</plist>
+12 -1
View File
@@ -18,7 +18,15 @@
"release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
"release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
"release:android:upload": "bundle exec fastlane android upload"
"release:android:upload": "bundle exec fastlane android upload",
"ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight",
"ota:preflight:production": "node scripts/ota-publish.mjs production --preflight",
"ota:publish:staging": "node scripts/ota-publish.mjs staging",
"ota:publish:production": "node scripts/ota-publish.mjs production",
"test:ota-publish": "node --test scripts/ota-publish.test.mjs",
"ota:rollback": "eoas rollback",
"ota:doctor:staging": "node scripts/ota-publish.mjs staging --doctor",
"ota:doctor:production": "node scripts/ota-publish.mjs production --doctor"
},
"dependencies": {
"@bacons/apple-targets": "^0.1.6",
@@ -62,6 +70,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.20",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@@ -72,6 +81,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-share": "^12.3.1",
"react-native-svg": "15.12.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^4.0.3",
@@ -87,6 +97,7 @@
"@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0",
"eoas": "^3.1.3",
"eslint": "^8.57.0",
"eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const projectDir = resolve(import.meta.dirname, '..');
const easConfig = JSON.parse(readFileSync(join(projectDir, 'eas.json'), 'utf8'));
const profiles = {
production: {
apiOrigin: 'https://api.sauci.app',
authOrigin: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
branch: 'production',
profile: 'production',
},
staging: {
apiOrigin: 'https://api.preprod.sauci.app',
authOrigin: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
branch: 'staging',
profile: 'preview',
},
};
const requiredPublicKeys = [
'EXPO_PUBLIC_API_URL',
'EXPO_PUBLIC_SUPABASE_URL',
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
];
function fail(message) {
throw new Error(`OTA publish preflight: ${message}`);
}
function requireCanonicalHttpsRoot(value, expected, label) {
try {
const parsed = new URL(value);
if (
value !== expected ||
parsed.protocol !== 'https:' ||
parsed.origin !== expected ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash ||
parsed.port
) {
fail(`${label} must be the canonical HTTPS root ${expected}`);
}
} catch {
fail(`${label} must be the canonical HTTPS root ${expected}`);
}
}
export function resolvePublishEnvironment(target, environment = process.env, config = easConfig) {
const profile = profiles[target];
if (!profile) fail(`target must be one of: ${Object.keys(profiles).join(', ')}`);
const profileEnv = config.build?.[profile.profile]?.env;
if (!profileEnv) fail(`missing EAS build profile ${profile.profile}`);
const env = { ...environment, ...profileEnv, RELEASE_CHANNEL: target };
for (const key of requiredPublicKeys) {
if (!env[key]) fail(`${key} is required for ${target} export`);
}
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`);
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`);
return { branch: profile.branch, env };
}
export function parsePublishArgs(args) {
const parsed = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
const takeValue = (flag, validate) => {
const value = args[index + 1];
if (!value || !validate(value)) fail(`${flag} requires a valid value`);
parsed.push(flag, value);
index += 1;
};
if (argument === '--platform') {
takeValue('--platform', (value) => ['ios', 'android', 'all'].includes(value));
} else if (argument.startsWith('--platform=')) {
const value = argument.slice('--platform='.length);
if (!['ios', 'android', 'all'].includes(value)) fail('--platform requires ios, android, or all');
parsed.push(argument);
} else if (argument === '-m' || argument === '--message') {
takeValue(argument, (value) => value.length > 0);
} else if (argument.startsWith('--message=')) {
if (!argument.slice('--message='.length)) fail('--message requires a value');
parsed.push(argument);
} else if (argument === '--rollout-percentage') {
takeValue(argument, (value) => /^(?:[1-9]|[1-9][0-9])$/.test(value));
} else if (argument.startsWith('--rollout-percentage=')) {
const value = argument.slice('--rollout-percentage='.length);
if (!/^(?:[1-9]|[1-9][0-9])$/.test(value)) fail('--rollout-percentage must be 1-99');
parsed.push(argument);
} else if (argument === '--nonInteractive') {
parsed.push(argument);
} else {
fail(`unsupported publish argument ${argument}`);
}
}
return parsed;
}
function run(command, args, env) {
const result = spawnSync(command, args, { cwd: projectDir, env, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
export function main(args = process.argv.slice(2), environment = process.env) {
const [target, ...remaining] = args;
const preflight = remaining.includes('--preflight');
const doctor = remaining.includes('--doctor');
const rawPublishArgs = remaining.filter((arg) => arg !== '--preflight' && arg !== '--doctor');
if (preflight && doctor) fail('choose either --preflight or --doctor');
const { branch, env } = resolvePublishEnvironment(target, environment);
const exportEnv = { ...env, EXPO_NO_DOTENV: '1' };
if (preflight) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], exportEnv);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
console.log(`OTA ${target} export preflight passed`);
return;
}
if (doctor) {
if (rawPublishArgs.length) fail('--doctor does not accept additional EOAS arguments');
run('npx', ['eoas', 'doctor', '--channel', branch], exportEnv);
return;
}
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
}
if (process.argv[1] === new URL(import.meta.url).pathname) main();
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parsePublishArgs, resolvePublishEnvironment } from './ota-publish.mjs';
const config = {
build: {
production: {
env: {
EXPO_PUBLIC_API_URL: 'https://api.sauci.app',
EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key',
},
},
preview: {
env: {
EXPO_PUBLIC_API_URL: 'https://api.preprod.sauci.app',
RELEASE_CHANNEL: 'staging',
},
},
},
};
test('production export uses the checked-in production profile', () => {
const { branch, env } = resolvePublishEnvironment('production', {}, config);
assert.equal(branch, 'production');
assert.equal(env.RELEASE_CHANNEL, 'production');
assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.sauci.app');
});
test('staging export requires the designated non-production Auth configuration', () => {
const environment = {
EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'staging-anon-key',
};
const { branch, env } = resolvePublishEnvironment('staging', environment, config);
assert.equal(branch, 'staging');
assert.equal(env.RELEASE_CHANNEL, 'staging');
assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.preprod.sauci.app');
});
test('staging export rejects production Auth configuration', () => {
assert.throws(
() => resolvePublishEnvironment('staging', {
EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key',
}, config),
/canonical HTTPS root/,
);
});
test('rejects malformed API and Auth roots', () => {
for (const invalidAuthUrl of [
'http://itbzhrvlgvdmzbnhzhyx.supabase.co',
'https://user@itbzhrvlgvdmzbnhzhyx.supabase.co',
'https://itbzhrvlgvdmzbnhzhyx.supabase.co/auth/v1',
'https://itbzhrvlgvdmzbnhzhyx.supabase.co?source=test',
'https://itbzhrvlgvdmzbnhzhyx.supabase.co#fragment',
'https://itbzhrvlgvdmzbnhzhyx.supabase.co:8443',
]) {
assert.throws(
() => resolvePublishEnvironment('staging', {
EXPO_PUBLIC_SUPABASE_URL: invalidAuthUrl,
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'staging-anon-key',
}, config),
/canonical HTTPS root/,
);
}
assert.throws(
() => resolvePublishEnvironment('staging', {
EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'staging-anon-key',
}, {
...config,
build: { ...config.build, preview: { env: { ...config.build.preview.env, EXPO_PUBLIC_API_URL: 'https://api.preprod.sauci.app/path' } } },
}),
/canonical HTTPS root/,
);
});
test('permits only documented safe publish arguments', () => {
assert.deepEqual(
parsePublishArgs(['--platform', 'ios', '-m', 'Release notes', '--rollout-percentage', '25', '--nonInteractive']),
['--platform', 'ios', '-m', 'Release notes', '--rollout-percentage', '25', '--nonInteractive'],
);
assert.deepEqual(
parsePublishArgs(['--platform=all', '--message=Release', '--rollout-percentage=99']),
['--platform=all', '--message=Release', '--rollout-percentage=99'],
);
});
test('rejects EOAS target, server, repository, and unknown overrides', () => {
for (const unsafe of [
'--branch',
'--branch=other',
'--channel',
'--channel=other',
'--serverUrl',
'--serverUrl=https://other.example',
'--server-url',
'--disableRepositoryCheck',
'--disable-repository-check',
'--outputDir',
'--unknown',
]) {
assert.throws(() => parsePublishArgs([unsafe]), /unsupported publish argument/);
}
});
@@ -0,0 +1,56 @@
const configPath = require.resolve("../../app.config");
const appJson = require("../../app.json");
type UpdateConfig = {
version: string;
runtimeVersion: string;
updates: {
codeSigningCertificate?: string;
codeSigningMetadata?: Record<string, string>;
};
};
function resolveConfig(environment: Record<string, string>): UpdateConfig {
const previous = {
DISABLE_CODE_SIGNING: process.env.DISABLE_CODE_SIGNING,
RELEASE_CHANNEL: process.env.RELEASE_CHANNEL,
};
Object.assign(process.env, environment);
jest.resetModules();
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return (require(configPath) as () => UpdateConfig)();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
describe("Expo OTA configuration", () => {
it("rejects disabling code signing for a production build", () => {
expect(() =>
resolveConfig({ RELEASE_CHANNEL: "production", DISABLE_CODE_SIGNING: "1" }),
).toThrow("RELEASE_CHANNEL=production does not permit DISABLE_CODE_SIGNING");
});
it("keeps local development code-signing opt-out available", () => {
const config = resolveConfig({ RELEASE_CHANNEL: "development", DISABLE_CODE_SIGNING: "1" });
expect(config.updates.codeSigningCertificate).toBeUndefined();
expect(config.updates.codeSigningMetadata).toBeUndefined();
});
it("resolves an explicit runtime version matching the public version", () => {
const config = resolveConfig({ RELEASE_CHANNEL: "production" });
expect(config.runtimeVersion).toBe(appJson.expo.version);
expect(config.runtimeVersion).toBe(config.version);
});
});
@@ -0,0 +1,89 @@
import { pickRecommendedPacks, RECOMMENDED_PACKS_LIMIT } from "@/features/home/recommendedPacks";
import type { Category, QuestionPack } from "@/types";
function makeCategory(overrides: Partial<Category>): Category {
return {
id: "cat-1",
name: "Category",
description: null,
icon: null,
color: null,
sort_order: 0,
created_at: "",
is_public: true,
...overrides,
};
}
function makePack(overrides: Partial<QuestionPack>): QuestionPack {
return {
id: "pack-1",
name: "Pack",
description: null,
icon: null,
is_premium: false,
is_public: true,
is_explicit: false,
sort_order: 0,
category_id: null,
...overrides,
} as QuestionPack;
}
describe("pickRecommendedPacks", () => {
const categories: Category[] = [
makeCategory({ id: "quality-time", name: "Quality Time" }),
makeCategory({ id: "romance", name: "Romance & Sensuality" }),
makeCategory({ id: "kink-lab", name: "The Kink Lab" }),
makeCategory({ id: "adventure", name: "Adventure & Travel" }),
];
const packs: QuestionPack[] = [
makePack({ id: "p-quality-1", category_id: "quality-time" }),
makePack({ id: "p-quality-2", category_id: "quality-time" }),
makePack({ id: "p-romance-1", category_id: "romance" }),
makePack({ id: "p-kink-1", category_id: "kink-lab" }),
makePack({ id: "p-adventure-1", category_id: "adventure" }),
makePack({ id: "p-uncategorized", category_id: null }),
];
it("returns an empty list when there is no usage reason", () => {
expect(pickRecommendedPacks(packs, categories, null)).toEqual([]);
expect(pickRecommendedPacks(packs, categories, undefined)).toEqual([]);
});
it("prioritizes packs from the categories mapped to the usage reason, in order", () => {
const result = pickRecommendedPacks(packs, categories, "reconnect");
// "reconnect" maps to quality time, long distance, romance (in that order);
// only quality time and romance exist in this catalog.
expect(result.map((pack) => pack.id)).toEqual([
"p-quality-1",
"p-quality-2",
"p-romance-1",
]);
});
it("matches a different usage reason to a different category set", () => {
const result = pickRecommendedPacks(packs, categories, "spice_up_intimacy");
expect(result.map((pack) => pack.id)).toEqual(["p-kink-1"]);
});
it("returns an empty list when no catalog category matches the mapped keywords", () => {
const noMatchCategories: Category[] = [makeCategory({ id: "misc", name: "Miscellaneous" })];
const result = pickRecommendedPacks(packs, noMatchCategories, "have_fun");
expect(result).toEqual([]);
});
it("caps the recommended list at RECOMMENDED_PACKS_LIMIT", () => {
const manyPacks = Array.from({ length: RECOMMENDED_PACKS_LIMIT + 5 }, (_, i) =>
makePack({ id: `p-${i}`, category_id: "quality-time" })
);
const result = pickRecommendedPacks(manyPacks, categories, "strengthen_relationship");
expect(result).toHaveLength(RECOMMENDED_PACKS_LIMIT);
});
});
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
import { ApiError, apiClient } from "@/lib/apiClient";
import { useAuthStore } from "@/store";
import * as swipeService from "@/features/swipe/services/swipeService";
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
@@ -15,12 +16,13 @@ jest.mock("expo-router", () => ({
// Stable identities: the hook's effects key off these, so fresh objects per render
// would retrigger the fetch effect forever and the screen would never settle.
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" }, sealedCount: 0 };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
useAuthStore.setState = jest.fn();
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
@@ -146,6 +148,19 @@ describe("useSwipeScreen daily limit", () => {
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
it("updates the sealed answer count optimistically when the server reports one", async () => {
// A solo answer has no partner to match against, so sealed_count is the
// only feedback the server gives back; the banner reads it from auth state.
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null, sealed_count: 3 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(useAuthStore.setState).toHaveBeenCalledWith({ sealedCount: 3 });
});
it("refreshes the shared streak once the answer lands", async () => {
const { result } = await renderReady();
mockFetchStreak.mockClear();
@@ -0,0 +1,34 @@
import { hasSeenAvatarPrompt, markAvatarPromptSeen } from "@/lib/avatarPromptSeen";
jest.mock("expo-secure-store", () => {
const store = new Map<string, string>();
return {
__store: store,
getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
setItemAsync: jest.fn(async (key: string, value: string) => { store.set(key, value); }),
};
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const secureStore = require("expo-secure-store") as { __store: Map<string, string> };
describe("avatarPromptSeen", () => {
beforeEach(() => {
secureStore.__store.clear();
});
it("reports unseen until marked seen for that user", async () => {
expect(await hasSeenAvatarPrompt("user-1")).toBe(false);
await markAvatarPromptSeen("user-1");
expect(await hasSeenAvatarPrompt("user-1")).toBe(true);
});
it("tracks the seen flag per user, so a shared device does not skip the prompt for a new account", async () => {
await markAvatarPromptSeen("user-1");
expect(await hasSeenAvatarPrompt("user-1")).toBe(true);
expect(await hasSeenAvatarPrompt("user-2")).toBe(false);
});
});
@@ -0,0 +1,73 @@
import { Linking } from "react-native";
const mockShareSingle = jest.fn();
const mockIsPackageInstalled = jest.fn();
jest.mock("react-native-share", () => ({
__esModule: true,
default: {
shareSingle: (...args: unknown[]) => mockShareSingle(...args),
isPackageInstalled: (...args: unknown[]) => mockIsPackageInstalled(...args),
},
Social: { InstagramStories: "instagramstories" },
}));
function loadShareToInstagramStories() {
let shareToInstagramStories: (uri: string) => Promise<boolean>;
jest.isolateModules(() => {
shareToInstagramStories = require("../../lib/instagramShare").shareToInstagramStories;
});
return shareToInstagramStories!;
}
describe("shareToInstagramStories", () => {
const originalAppId = process.env.EXPO_PUBLIC_FACEBOOK_APP_ID;
afterEach(() => {
if (originalAppId === undefined) {
delete process.env.EXPO_PUBLIC_FACEBOOK_APP_ID;
} else {
process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = originalAppId;
}
});
it("returns false without opening Instagram when no Facebook App ID is configured", async () => {
delete process.env.EXPO_PUBLIC_FACEBOOK_APP_ID;
const share = loadShareToInstagramStories();
await expect(share("file:///tmp/card.png")).resolves.toBe(false);
expect(mockShareSingle).not.toHaveBeenCalled();
});
it("returns false when Instagram is not installed", async () => {
process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = "123456";
jest.spyOn(Linking, "canOpenURL").mockResolvedValue(false);
const share = loadShareToInstagramStories();
await expect(share("file:///tmp/card.png")).resolves.toBe(false);
expect(mockShareSingle).not.toHaveBeenCalled();
});
it("shares the captured image as the story background when Instagram is available", async () => {
process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = "123456";
jest.spyOn(Linking, "canOpenURL").mockResolvedValue(true);
mockShareSingle.mockResolvedValue({ success: true });
const share = loadShareToInstagramStories();
await expect(share("file:///tmp/card.png")).resolves.toBe(true);
expect(mockShareSingle).toHaveBeenCalledWith({
social: "instagramstories",
appId: "123456",
backgroundImage: "file:///tmp/card.png",
});
});
it("returns false when the native share fails", async () => {
process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = "123456";
jest.spyOn(Linking, "canOpenURL").mockResolvedValue(true);
mockShareSingle.mockRejectedValue(new Error("share failed"));
const share = loadShareToInstagramStories();
await expect(share("file:///tmp/card.png")).resolves.toBe(false);
});
});
@@ -0,0 +1,22 @@
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
describe("buildInviteShareMessage", () => {
it("leads with a plain invite when there are no sealed answers", () => {
const message = buildInviteShareMessage("ABCD1234", 0);
expect(message).toBe(
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
});
it("leads with earned value and singular wording for exactly one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 1);
expect(message).toBe(
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
});
it("pluralises for more than one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 5);
expect(message).toContain("I have already answered 5 questions about us.");
});
});
@@ -30,10 +30,15 @@ const profile = {
describe("authStore", () => {
beforeEach(() => {
jest.clearAllMocks();
// fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape.
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
@@ -89,12 +94,12 @@ describe("authStore", () => {
max_intensity: 3,
};
useAuthStore.setState({ user: pairedProfile } as any);
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner });
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner, sealed_count: 3 });
await useAuthStore.getState().fetchCouple();
expect(stateSpy).toHaveBeenCalledWith();
expect(useAuthStore.getState()).toMatchObject({ couple, partner });
expect(useAuthStore.getState()).toMatchObject({ couple, partner, sealedCount: 3 });
});
it("updates activity through the standalone API", async () => {
@@ -107,6 +112,7 @@ describe("authStore", () => {
});
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
useAuthStore.setState({ sealedCount: 3 } as any);
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me" } } },
});
@@ -117,6 +123,7 @@ describe("authStore", () => {
expect(useAuthStore.getState()).toMatchObject({
user: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
@@ -127,4 +134,28 @@ describe("authStore", () => {
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
expect(authClient.auth.signOut).toHaveBeenCalled();
});
it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
await useAuthStore.getState().signOut();
expect(useAuthStore.getState()).toMatchObject({
user: null,
sealedCount: 0,
isAuthenticated: false,
});
});
it("clears sealed answers when the auth listener clears the user", () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
useAuthStore.getState().setUser(null);
expect(useAuthStore.getState()).toMatchObject({
user: null,
sealedCount: 0,
isAuthenticated: false,
});
});
});
@@ -0,0 +1,125 @@
/**
* AvatarPromptCard - Lightweight, dismissible nudge shown once after a
* couple's first match, inviting a user with no profile photo to add one.
* Renders after the match celebration completes, never on top of it.
*/
import React from 'react';
import { StyleSheet, View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInUp, FadeOutDown } from 'react-native-reanimated';
import { GlassCard } from './ui/GlassCard';
import { colors, spacing, radius, typography } from '../theme';
interface Props {
visible: boolean;
onAddPhoto: () => void;
onDismiss: () => void;
}
export const AvatarPromptCard: React.FC<Props> = ({ visible, onAddPhoto, onDismiss }) => {
if (!visible) return null;
return (
<View style={styles.wrapper} pointerEvents="box-none">
<Animated.View
entering={FadeInUp.duration(400)}
exiting={FadeOutDown.duration(250)}
>
<GlassCard style={styles.card} variant="elevated">
<View style={styles.row}>
<View style={styles.iconCircle}>
<Ionicons name="camera" size={20} color={colors.primary} />
</View>
<View style={styles.textColumn}>
<Text style={styles.title}>Add a profile photo</Text>
<Text style={styles.subtitle}>
Let your partner see it's you
</Text>
</View>
</View>
<View style={styles.actions}>
<Pressable
style={styles.notNowButton}
onPress={onDismiss}
testID="avatar-prompt-not-now"
>
<Text style={styles.notNowText}>Not now</Text>
</Pressable>
<Pressable
style={styles.addPhotoButton}
onPress={onAddPhoto}
testID="avatar-prompt-add-photo"
>
<Text style={styles.addPhotoText}>Add photo</Text>
</Pressable>
</View>
</GlassCard>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
bottom: spacing.xxl + spacing.lg,
zIndex: 20,
},
card: {
marginBottom: 0,
},
row: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
},
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
textColumn: {
flex: 1,
},
title: {
...typography.headline,
color: colors.text,
},
subtitle: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
notNowButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
notNowText: {
...typography.subhead,
color: colors.textSecondary,
},
addPhotoButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.primary,
},
addPhotoText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
export default AvatarPromptCard;
@@ -0,0 +1,28 @@
import { render } from "@/test/test-utils";
import { QuestionShareCard } from "./QuestionShareCard";
describe("QuestionShareCard", () => {
it("renders the question, pack badge, and Sauci branding", () => {
const { getByText } = render(
<QuestionShareCard
question={{ text: "What's your idea of a perfect date night?" }}
packName="Date Night"
cardWidth={300}
/>,
);
expect(getByText("What's your idea of a perfect date night?")).toBeTruthy();
expect(getByText("Date Night")).toBeTruthy();
expect(getByText("Sauci")).toBeTruthy();
expect(getByText("sauci.app")).toBeTruthy();
expect(getByText(/play it with your partner/i)).toBeTruthy();
});
it("never renders a couple join link — the pairing code is private", () => {
const { queryByText } = render(
<QuestionShareCard question={{ text: "A question" }} cardWidth={300} />,
);
expect(queryByText(/join\//)).toBeNull();
});
});
@@ -0,0 +1,158 @@
import { Image, StyleSheet, Text, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { colors, gradients, radius, spacing, typography } from "../../theme";
const logo = require("../../../assets/logo.png");
interface QuestionShareCardProps {
question: {
text: string;
};
packName?: string;
cardColor?: string;
cardWidth: number;
}
/**
* The story card captured for outbound shares (Instagram Stories, Messages,
* share sheet). Carries the question and Sauci branding only — never the
* sharer's couple join code, which is a private pairing credential, not a
* referral link.
*/
export function QuestionShareCard({
question,
packName,
cardColor,
cardWidth,
}: QuestionShareCardProps) {
const useGradient = !cardColor;
return (
<View style={[styles.card, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl },
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.overlay} />
{/* Header: wordmark + pack badge */}
<View style={styles.header}>
<View style={styles.wordmark}>
<Image source={logo} style={styles.wordmarkLogo} />
<Text style={styles.wordmarkText}>Sauci</Text>
</View>
{packName ? (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
) : null}
</View>
{/* Question */}
<View style={styles.content}>
<Text style={styles.questionText}>{question.text}</Text>
</View>
{/* Branded footer */}
<View style={styles.footer}>
<Text style={styles.footerPrompt}>Play it with your partner</Text>
<View style={styles.linkPill}>
<Text style={styles.linkText}>sauci.app</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: "hidden",
padding: spacing.lg,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.1)",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
wordmark: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
wordmarkLogo: {
width: 22,
height: 22,
resizeMode: "contain",
},
wordmarkText: {
...typography.subhead,
color: colors.text,
fontWeight: "700",
},
packBadge: {
flexShrink: 1,
backgroundColor: "rgba(255, 255, 255, 0.2)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: "600",
},
content: {
flex: 1,
justifyContent: "center",
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: "700",
lineHeight: 36,
},
footer: {
alignItems: "center",
gap: spacing.sm,
},
footerPrompt: {
...typography.caption1,
color: "rgba(255, 255, 255, 0.85)",
fontWeight: "600",
letterSpacing: 1,
textTransform: "uppercase",
},
linkPill: {
backgroundColor: "rgba(255, 255, 255, 0.95)",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
},
linkText: {
...typography.subhead,
color: "#1a1a2e",
fontWeight: "700",
},
});
@@ -6,10 +6,8 @@ import {
Modal,
TouchableOpacity,
Platform,
Linking,
Share,
useWindowDimensions,
Image,
} from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
@@ -23,10 +21,10 @@ import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system/legacy";
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
const logo = require("../../../assets/logo.png");
import { colors, typography, spacing, blur } from "../../theme";
import { shareToInstagramStories } from "../../lib/instagramShare";
import { Events } from "../../lib/analytics";
import { QuestionShareCard } from "./QuestionShareCard";
const MAX_CARD_WIDTH = 340;
@@ -56,6 +54,8 @@ export function SharePreviewModal({
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const shareMessage = `${question.text}\n\nPlay it with your partner on https://sauci.app`;
// Capture the card when modal becomes visible
useEffect(() => {
if (visible && shareViewRef.current) {
@@ -87,31 +87,11 @@ export function SharePreviewModal({
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
// Check if Instagram is installed
const instagramUrl = 'instagram://app';
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === 'ios') {
// For iOS, we can use the Instagram Stories URL scheme
// First, we need to copy the image to a location Instagram can access
const base64 = await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
// Use Instagram Stories URL scheme
const instagramStoriesUrl = `instagram-stories://share?source_application=com.sauci.app`;
// Try to open Instagram Stories with the image
// Note: This requires the image to be in the pasteboard on iOS
await Linking.openURL(instagramStoriesUrl);
} else {
// Fallback to regular sharing
await handleMoreShare();
}
} catch (error) {
console.error('Instagram share error:', error);
// Fallback to regular sharing
const shared = await shareToInstagramStories(capturedUri);
if (shared) {
Events.questionShared("instagram");
} else {
// Instagram Stories sharing unavailable - fall back to the share sheet
await handleMoreShare();
}
};
@@ -119,9 +99,8 @@ export function SharePreviewModal({
const handleMessagesShare = async () => {
if (!capturedUri) {
// Fallback to text
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
Events.questionShared("messages");
return;
}
@@ -132,22 +111,21 @@ export function SharePreviewModal({
mimeType: 'image/png',
UTI: 'public.png',
});
Events.questionShared("messages");
} else {
await handleMoreShare();
}
} catch (error) {
console.error('Messages share error:', error);
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
Events.questionShared("messages");
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
Events.questionShared("more");
return;
}
@@ -159,10 +137,9 @@ export function SharePreviewModal({
dialogTitle: 'Share this question',
});
} else {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
}
Events.questionShared("more");
} catch (error) {
console.error('Share error:', error);
}
@@ -225,7 +202,7 @@ export function SharePreviewModal({
result: 'tmpfile',
}}
>
<ShareableCard
<QuestionShareCard
question={question}
packName={packName}
cardWidth={cardWidth}
@@ -310,69 +287,6 @@ function ShareActionButton({
);
}
function ShareableCard({
question,
packName,
cardWidth,
cardColor,
}: {
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardWidth: number;
cardColor?: string;
}) {
// Use the card color if provided, otherwise fall back to primary gradient
const useGradient = !cardColor;
return (
<View style={[styles.shareableCard, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl }
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.cardOverlay} />
{/* Pack name badge */}
{packName && (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
)}
{/* Content */}
<View style={styles.shareableContent}>
<Text style={styles.questionText}>
{question.text}
</Text>
</View>
{/* Branding with logo */}
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
@@ -441,82 +355,4 @@ const styles = StyleSheet.create({
color: colors.text,
fontWeight: '500',
},
// Shareable card styles
shareableCard: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: 'hidden',
padding: spacing.lg,
},
cardOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
packBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
shareableContent: {
flex: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: '700',
lineHeight: 36,
},
questionTextSmaller: {
...typography.title2,
lineHeight: 30,
},
partnerSection: {
marginTop: spacing.lg,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
gap: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
},
partnerText: {
...typography.subhead,
color: 'rgba(255, 255, 255, 0.8)',
fontStyle: 'italic',
},
branding: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: 'contain',
},
brandingText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
+2 -1
View File
@@ -1 +1,2 @@
export { SharePreviewModal } from './SharePreviewModal';
export { SharePreviewModal } from './SharePreviewModal';
export { QuestionShareCard } from './QuestionShareCard';
@@ -0,0 +1,78 @@
import type { Category, QuestionPack, UsageReason } from '../../types';
/**
* Category-name keywords to prioritize for each onboarding usage reason,
* matched case-insensitively against the couple's actual catalog. Ordered
* from most to least relevant so the first matching category's packs lead
* the recommended row.
*
* Keep this in sync with the categories seeded in the content catalog
* (see apps/supabase/content-seed.sql) - it degrades gracefully to no
* recommendation if none of the keywords match anything in the catalog.
*/
export const USAGE_REASON_CATEGORY_KEYWORDS: Record<UsageReason, string[]> = {
improve_communication: ['getting started', 'quality time', 'social life'],
reconnect: ['quality time', 'long distance', 'romance'],
deeper_connection: ['romance', 'sensual discovery', 'getting started'],
have_fun: ['adventure', 'spicy challenges', 'roleplay', 'public thrills'],
strengthen_relationship: ['quality time', 'romance', 'getting started'],
spice_up_intimacy: ['bedroom adventures', 'fantasy exploration', 'the kink lab', 'toy time'],
};
export const RECOMMENDED_PACKS_LABEL = 'Picked for how you want to use Sauci';
/** Caps the recommended row so it stays a highlight, not a full re-listing of the catalog. */
export const RECOMMENDED_PACKS_LIMIT = 8;
/**
* Picks a personalized set of packs based on the usage reason collected during
* onboarding. Pure and side-effect free so it can be unit tested directly.
* Returns an empty array whenever there is nothing to recommend (no reason on
* file, or no catalog category matches its keywords) so callers can simply
* skip rendering the row.
*/
export function pickRecommendedPacks(
packs: QuestionPack[],
categories: Category[],
usageReason: UsageReason | null | undefined,
): QuestionPack[] {
if (!usageReason) return [];
const keywords = USAGE_REASON_CATEGORY_KEYWORDS[usageReason];
if (!keywords || keywords.length === 0) return [];
const orderedCategoryIds: string[] = [];
for (const keyword of keywords) {
for (const category of categories) {
if (
category.name.toLowerCase().includes(keyword) &&
!orderedCategoryIds.includes(category.id)
) {
orderedCategoryIds.push(category.id);
}
}
}
if (orderedCategoryIds.length === 0) return [];
const packsByCategoryId = new Map<string, QuestionPack[]>();
for (const pack of packs) {
if (!pack.category_id) continue;
const list = packsByCategoryId.get(pack.category_id) ?? [];
list.push(pack);
packsByCategoryId.set(pack.category_id, list);
}
const recommended: QuestionPack[] = [];
for (const categoryId of orderedCategoryIds) {
if (recommended.length >= RECOMMENDED_PACKS_LIMIT) break;
const categoryPacks = packsByCategoryId.get(categoryId) ?? [];
for (const pack of categoryPacks) {
if (recommended.length >= RECOMMENDED_PACKS_LIMIT) break;
if (!recommended.some((existing) => existing.id === pack.id)) {
recommended.push(pack);
}
}
}
return recommended;
}
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import {
Linking,
Modal,
Platform,
Share,
@@ -16,8 +15,8 @@ import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system/legacy";
import { colors, spacing, typography, blur } from "../../../theme";
import { shareToInstagramStories } from "../../../lib/instagramShare";
import { Events } from "../../../lib/analytics";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
@@ -63,22 +62,11 @@ export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModa
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
const instagramUrl = "instagram://app";
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === "ios") {
await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
const instagramStoriesUrl = "instagram-stories://share?source_application=com.sauci.app";
await Linking.openURL(instagramStoriesUrl);
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Instagram share error:", error);
const shared = await shareToInstagramStories(capturedUri);
if (shared) {
Events.quizShared();
} else {
// Instagram Stories sharing unavailable - fall back to the share sheet
await handleMoreShare();
}
};
+26 -11
View File
@@ -4,13 +4,14 @@ import { useRouter } from "expo-router";
import { GradientBackground } from "../../components/ui";
import { QuestionFeedbackModal } from "../../components/feedback";
import { MatchConfetti } from "../../components/MatchConfetti";
import { AvatarPromptCard } from "../../components/AvatarPromptCard";
import { Paywall } from "../../components/paywall";
import { useSwipeScreen } from "./hooks/useSwipeScreen";
import { SwipeCardStack } from "./components/SwipeCardStack";
import { SwipeHeader } from "./components/SwipeHeader";
import { SwipeUploadOverlay } from "./components/SwipeUploadOverlay";
import { SwipeLoadingState } from "./components/SwipeLoadingState";
import { SwipeNoPartnerState } from "./components/SwipeNoPartnerState";
import { SwipeSealedAnswersBanner } from "./components/SwipeSealedAnswersBanner";
import { SwipeDailyLimitState } from "./components/SwipeDailyLimitState";
import { SwipeBlockedState } from "./components/SwipeBlockedState";
import { SwipeNoPacksState } from "./components/SwipeNoPacksState";
@@ -32,15 +33,19 @@ const SwipeScreen = () => {
showPaywall,
packContext,
showConfetti,
showAvatarPrompt,
countdown,
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,
handleAnswer,
handleConfettiComplete,
handleAvatarPromptAddPhoto,
handleAvatarPromptDismiss,
getQuestionPackInfo,
checkAnswerGap,
setFeedbackQuestion,
@@ -51,15 +56,6 @@ const SwipeScreen = () => {
return <SwipeLoadingState />;
}
if (!partner) {
return (
<SwipeNoPartnerState
hasCouple={!!couple}
onPairPress={() => router.push("/pairing")}
/>
);
}
if (dailyLimitInfo?.is_blocked) {
return (
<SwipeDailyLimitState
@@ -85,7 +81,10 @@ const SwipeScreen = () => {
);
}
if (!packId && enabledPackIds.length === 0) {
// Pack selection is stored per couple, so it only exists once paired. A solo
// user cannot have configured packs yet; recommended() already falls back to
// every public pack for them, so this gate would otherwise be a dead end.
if (couple && !packId && enabledPackIds.length === 0) {
return (
<SwipeNoPacksState onBrowsePacks={() => router.push("/")} />
);
@@ -118,6 +117,13 @@ const SwipeScreen = () => {
dailyLimitInfo={dailyLimitInfo}
/>
{!partner && (
<SwipeSealedAnswersBanner
sealedCount={sealedCount}
onInvitePress={() => router.push("/pairing")}
/>
)}
<SwipeCardStack
questions={filteredQuestions}
currentIndex={currentIndex}
@@ -143,6 +149,15 @@ const SwipeScreen = () => {
onAnimationComplete={handleConfettiComplete}
/>
<AvatarPromptCard
visible={showAvatarPrompt}
onAddPhoto={() => {
handleAvatarPromptAddPhoto();
router.push('/(app)/settings/profile');
}}
onDismiss={handleAvatarPromptDismiss}
/>
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
@@ -2,6 +2,7 @@ import { Share } from "react-native";
import { colors } from "../../../theme";
import { useAuthStore } from "../../../store";
import { Events } from "../../../lib/analytics";
import { buildInviteShareMessage } from "../../../lib/inviteShareCopy";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeNoPartnerStateProps {
@@ -11,13 +12,13 @@ interface SwipeNoPartnerStateProps {
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
const accent = colors.premium.rose;
const { couple } = useAuthStore();
const { couple, sealedCount } = useAuthStore();
const handleShareCode = async () => {
if (!couple?.invite_code) return;
try {
await Share.share({
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
message: buildInviteShareMessage(couple.invite_code, sealedCount),
});
Events.codeShared();
} catch (error) {
@@ -0,0 +1,56 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { colors, radius, spacing, typography } from "../../../theme";
interface SwipeSealedAnswersBannerProps {
sealedCount: number;
onInvitePress: () => void;
}
/**
* An unpaired user answers straight into the swipe flow now instead of hitting a
* dead-end waiting room. This banner keeps the invite ask visible without blocking
* play, and grows more concrete as the sealed count climbs.
*/
export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSealedAnswersBannerProps) => {
if (sealedCount <= 0) return null;
return (
<TouchableOpacity
style={styles.container}
onPress={onInvitePress}
activeOpacity={0.8}
testID="swipe-sealed-answers-banner"
>
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
<Text style={styles.text}>
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
</Text>
<Text style={styles.cta}>Invite</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.full,
backgroundColor: colors.backgroundLight,
},
text: {
...typography.caption1,
color: colors.textSecondary,
flex: 1,
},
cta: {
...typography.caption1,
color: colors.premium.rose,
fontWeight: "700",
},
});
@@ -5,6 +5,7 @@ import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
import { hasSeenAvatarPrompt, markAvatarPromptSeen } from "../../../lib/avatarPromptSeen";
import type { AnswerType } from "../../../types";
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
import {
@@ -45,10 +46,12 @@ export const useSwipeScreen = () => {
const [showPaywall, setShowPaywall] = useState(false);
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
const [showConfetti, setShowConfetti] = useState(false);
const [showAvatarPrompt, setShowAvatarPrompt] = useState(false);
const [countdown, setCountdown] = useState<string>("");
const hasEvaluatedAvatarPrompt = useRef(false);
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
const { user, partner, couple, sealedCount } = useAuthStore();
const filteredQuestions = filterSupportedQuestions(questions);
@@ -128,20 +131,15 @@ export const useSwipeScreen = () => {
filtered = data;
}
if (partner) {
// The daily limit applies to pack browsing too, so it is read in both
// modes; the answer gap is deliberately only enforced outside packs.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
} else {
// The daily limit applies solo too, since a sealed answer still counts
// against it; the answer gap only makes sense once there is a partner
// to be ahead of, and checkAnswerGap already no-ops without one.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
if (currentRequestId === fetchRequestId.current) {
@@ -156,7 +154,7 @@ export const useSwipeScreen = () => {
setIsLoading(false);
}
}
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
}, [packId, checkAnswerGap, checkDailyLimit]);
const fetchPending = useCallback(async () => {
const currentRequestId = ++fetchRequestId.current;
@@ -315,7 +313,7 @@ export const useSwipeScreen = () => {
uploadResponseMedia,
});
const data = await apiClient.post<{ match: unknown | null }>("/v1/responses", {
const data = await apiClient.post<{ match: unknown | null; sealed_count?: number }>("/v1/responses", {
question_id: questionId,
answer,
response_data: finalResponseData,
@@ -323,6 +321,13 @@ export const useSwipeScreen = () => {
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
// A solo answer has no partner to match against; the sealed count is
// this answer's only visible feedback, so the banner updates instantly
// instead of waiting for the couple state to refresh next time round.
if (typeof data?.sealed_count === 'number') {
useAuthStore.setState({ sealedCount: data.sealed_count });
}
const hasMatch = data?.match != null;
if (hasMatch) {
@@ -387,9 +392,40 @@ export const useSwipeScreen = () => {
}
}, [checkAnswerGap, checkDailyLimit, currentIndex, dailyLimitInfo, filteredQuestions, mode, user?.id]);
const maybeShowAvatarPrompt = useCallback(async () => {
const currentUser = useAuthStore.getState().user;
if (!currentUser?.id || currentUser.avatar_url) return;
const alreadySeen = await hasSeenAvatarPrompt(currentUser.id);
if (alreadySeen) return;
// Marked seen as soon as it is shown, so the prompt appears at most once
// even if the user leaves the app before responding to it.
await markAvatarPromptSeen(currentUser.id);
setShowAvatarPrompt(true);
Events.avatarPromptShown('first_match');
}, []);
const handleConfettiComplete = useCallback(() => {
setShowConfetti(false);
setCurrentIndex(prev => prev + 1);
// Deferred avatar ask: offered once, right after the couple's first match
// celebration finishes, never on top of it.
if (!hasEvaluatedAvatarPrompt.current) {
hasEvaluatedAvatarPrompt.current = true;
void maybeShowAvatarPrompt();
}
}, [maybeShowAvatarPrompt]);
const handleAvatarPromptAddPhoto = useCallback(() => {
setShowAvatarPrompt(false);
Events.avatarPromptAccepted('first_match');
}, []);
const handleAvatarPromptDismiss = useCallback(() => {
setShowAvatarPrompt(false);
Events.avatarPromptDismissed('first_match');
}, []);
const getQuestionPackInfo = useCallback(
@@ -419,15 +455,19 @@ export const useSwipeScreen = () => {
showPaywall,
packContext,
showConfetti,
showAvatarPrompt,
countdown,
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,
handleAnswer,
handleConfettiComplete,
handleAvatarPromptAddPhoto,
handleAvatarPromptDismiss,
getQuestionPackInfo,
checkAnswerGap,
setFeedbackQuestion,
+6
View File
@@ -214,6 +214,8 @@ export const Events = {
questionAnswered: (answer: string, packId?: string) =>
logEvent("question_answered", { answer, pack_id: packId ?? "unknown" }),
questionSkipped: () => logEvent("question_skipped"),
questionShared: (destination: "instagram" | "messages" | "more") =>
logEvent("question_shared", { destination }),
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
pendingQuestionsPromptViewed: (count: number) =>
@@ -257,6 +259,10 @@ export const Events = {
// Profile events
profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }),
avatarUploaded: () => logEvent("avatar_uploaded"),
avatarSkipped: () => logEvent("avatar_skipped"),
avatarPromptShown: (source: string) => logEvent("avatar_prompt_shown", { source }),
avatarPromptAccepted: (source: string) => logEvent("avatar_prompt_accepted", { source }),
avatarPromptDismissed: (source: string) => logEvent("avatar_prompt_dismissed", { source }),
// Notification events
notificationPermissionGranted: () => logEvent("notification_permission_granted"),
+54
View File
@@ -0,0 +1,54 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY_PREFIX = "avatar_prompt_seen";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(key);
}
} else {
data = await SecureStore.getItemAsync(key);
}
return data === "true";
} catch (error) {
console.error("Error reading avatar prompt state:", error);
}
return false;
}
async function setStorage(key: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, data);
}
} else {
await SecureStore.setItemAsync(key, data);
}
} catch (error) {
console.error("Error saving avatar prompt state:", error);
}
}
/**
* Whether this user has already been shown the deferred avatar prompt (at the
* first-match moment), regardless of whether they added a photo or dismissed it.
* The prompt is shown at most once per user.
*/
export async function hasSeenAvatarPrompt(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markAvatarPromptSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+2
View File
@@ -4,6 +4,8 @@ import { apiRequest } from "./apiClient";
export interface CoupleStateResponse {
couple: Couple | null;
partner: Profile | null;
/** Count of this user's banked answers with no couple yet (couple_id IS NULL). */
sealed_count: number;
}
export interface CoupleMutationResponse {
+48
View File
@@ -0,0 +1,48 @@
import { Linking, Platform } from "react-native";
import Share, { Social } from "react-native-share";
// Instagram requires a registered Facebook App ID for the Stories share flow
// (https://developers.facebook.com/docs/instagram/sharing-to-stories/).
const facebookAppId = process.env.EXPO_PUBLIC_FACEBOOK_APP_ID;
const INSTAGRAM_ANDROID_PACKAGE = "com.instagram.android";
async function isInstagramStoriesAvailable(): Promise<boolean> {
if (!facebookAppId) return false;
try {
if (Platform.OS === "ios") {
return await Linking.canOpenURL("instagram-stories://share");
}
if (Platform.OS === "android") {
const { isInstalled } = await Share.isPackageInstalled(INSTAGRAM_ANDROID_PACKAGE);
return isInstalled;
}
return false;
} catch {
return false;
}
}
/**
* Share a locally captured image (file:// URI) to Instagram Stories as the
* story background. Returns true when the share was handed off to Instagram,
* false when Instagram Stories sharing is unavailable (not installed, no
* Facebook App ID configured, or the user dismissed the native prompt) so
* callers can fall back to the system share sheet.
*/
export async function shareToInstagramStories(imageUri: string): Promise<boolean> {
if (!(await isInstagramStoriesAvailable())) return false;
try {
const result = await Share.shareSingle({
social: Social.InstagramStories,
appId: facebookAppId as string,
backgroundImage: imageUri,
});
return result.success !== false;
} catch (error) {
console.error("Instagram Stories share error:", error);
return false;
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Builds the message shared when inviting a partner to pair. Once the inviter has
* sealed answers banked (answered questions solo, before a partner joined), the
* copy leads with that earned value instead of a plain invite, because it gives
* the recipient a concrete reason to join right away.
*/
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`;
if (sealedCount > 0) {
const noun = sealedCount === 1 ? "question" : "questions";
return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`;
}
return `Join me on Sauci! Tap this link to pair up instantly: ${link}`;
}
+13 -17
View File
@@ -12,6 +12,8 @@ interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
/** This user's banked (sealed) answers with no couple yet. Zero once claimed at pairing. */
sealedCount: number;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
@@ -44,6 +46,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
@@ -77,18 +80,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isAuthenticated: true, isAnonymous });
}
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
// Always fetch couple state: it reports the sealed answer count for a
// solo user even before any couple exists, not just once paired.
await get().fetchCouple();
set({ isLoading: false });
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
@@ -107,14 +107,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) {
set({ couple: null, partner: null });
return;
}
const { couple, partner } = await coupleApi.getState();
set({ couple, partner });
// Fetches unconditionally: getState() reports sealed_count for a solo user
// who has no couple yet too, not only once paired.
const { couple, partner, sealed_count } = await coupleApi.getState();
set({ couple, partner, sealedCount: sealed_count });
},
/**
@@ -141,6 +137,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
@@ -175,7 +172,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
...(user === null && { couple: null, partner: null, sealedCount: 0 })
});
// Clear other stores when user signs out
if (user === null) {
@@ -215,4 +212,3 @@ export const useAuthStore = create<AuthState>((set, get) => ({
void syncTimezone(userId);
},
}));
+57 -47
View File
@@ -1,6 +1,14 @@
// User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
/** Why the user says they came to Sauci, collected during onboarding. */
export type UsageReason =
| 'improve_communication'
| 'reconnect'
| 'spice_up_intimacy'
| 'deeper_connection'
| 'have_fun'
| 'strengthen_relationship';
export interface Profile {
id: string;
@@ -11,6 +19,8 @@ export interface Profile {
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
/** Why the user came to Sauci, if they shared it during onboarding. */
usage_reason?: UsageReason | null;
show_explicit_content: boolean;
max_intensity: IntensityLevel;
hide_nsfw: boolean;
@@ -63,55 +73,55 @@ export interface QuestionPack {
}
// Individual question
export interface Question {
id: string;
pack_id: string;
text: string;
partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
/** Type of question interaction (default: 'swipe') */
question_type?: QuestionType;
/** Configuration options for specific question types */
config?: QuestionConfig | null;
created_at: string;
}
// User's answer type
export type AnswerType = 'yes' | 'no' | 'maybe';
// Question type (default is 'swipe' for backwards compatibility)
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
// Question configuration options
export interface QuestionConfig {
max_duration_seconds?: number; // For audio questions (default: 60)
}
// Response data for non-swipe question types
export type ResponseData =
| { type: 'text_answer'; text: string }
| { type: 'audio'; media_path: string; duration_seconds: number }
| { type: 'photo'; media_path: string }
| { type: 'who_likely'; chosen_user_id: string };
// Match type when both partners agree
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
export interface Question {
id: string;
pack_id: string;
text: string;
partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
/** Type of question interaction (default: 'swipe') */
question_type?: QuestionType;
/** Configuration options for specific question types */
config?: QuestionConfig | null;
created_at: string;
}
// User's answer type
export type AnswerType = 'yes' | 'no' | 'maybe';
// Question type (default is 'swipe' for backwards compatibility)
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
// Question configuration options
export interface QuestionConfig {
max_duration_seconds?: number; // For audio questions (default: 60)
}
// Response data for non-swipe question types
export type ResponseData =
| { type: 'text_answer'; text: string }
| { type: 'audio'; media_path: string; duration_seconds: number }
| { type: 'photo'; media_path: string }
| { type: 'who_likely'; chosen_user_id: string };
// Match type when both partners agree
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
// User response to a question
export interface Response {
id: string;
user_id: string;
question_id: string;
couple_id: string;
answer: AnswerType;
/** Additional response data for non-swipe question types */
response_data?: ResponseData | null;
created_at: string;
}
export interface Response {
id: string;
user_id: string;
question_id: string;
couple_id: string;
answer: AnswerType;
/** Additional response data for non-swipe question types */
response_data?: ResponseData | null;
created_at: string;
}
// Match between partners
export interface Match {
+3 -3
View File
@@ -8,8 +8,8 @@
<string>com.apple.widgetkit-extension</string>
</dict>
<key>CFBundleVersion</key>
<string>45</string>
<string>46</string>
<key>CFBundleShortVersionString</key>
<string>1.0.6</string>
<string>1.0.7</string>
</dict>
</plist>
</plist>
+2 -1
View File
@@ -6,5 +6,6 @@ Next.js App Router marketing, legal, and redemption UI.
- Preserve PostHog provider/page-view integration unless the task changes it.
- Keep metadata current for public pages.
- Never expose server credentials through `NEXT_PUBLIC_*` variables.
- Public redemption must use sandbox/local Supabase during automated tests.
- Public redemption E2E must use the loopback standalone API and PostgreSQL
fixture harness, never the retired Supabase data plane.
- Verify lint, typecheck, build, and the relevant Playwright flow.
+8 -2
View File
@@ -6,6 +6,12 @@ export const metadata: Metadata = {
description: 'Open Sauci and pair up instantly with your invite code.',
}
export default function JoinPage({ params }: { params: { code: string } }) {
return <JoinClient code={params.code} />
export default async function JoinPage({
params,
}: {
params: Promise<{ code: string }>
}) {
const { code } = await params
return <JoinClient code={code} />
}
+3 -3
View File
@@ -6,14 +6,14 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"framer-motion": "^11.0.0",
"lucide-react": "^0.460.0",
"next": "^14.2.0",
"next": "15.5.24",
"posthog-js": "^1.311.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -24,7 +24,7 @@
"@types/react-dom": "~19.1.7",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.35",
"eslint-config-next": "15.5.24",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0"
+1
View File
@@ -12,6 +12,7 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"target": "ES2017",
"plugins": [
{
"name": "next"
+48
View File
@@ -16,6 +16,54 @@ A user can be in one of three states:
| **Waiting** | Set | `null` | Created couple, waiting for partner |
| **Paired** | Set | Exists | Both partners joined |
Unpaired and Waiting are not dead ends: both states can answer questions
immediately through the standalone API (`POST /v1/responses`). See
[Solo answering and sealed answers](#solo-answering-and-sealed-answers) below.
## Solo answering and sealed answers
An Unpaired or Waiting user answers straight into the swipe flow instead of
being blocked until a partner joins. `responses.couple_id` is nullable
(`apps/api/drizzle/0022_solo_sealed_answers.sql`): a response submitted with no
couple is inserted with `couple_id IS NULL` and is called a **sealed answer**.
It is banked, not lost, and counts toward the daily response limit like any
other answer, but no match reconciliation runs for it (there is no partner
response to compare against).
`GET /v1/couple` reports `sealed_count`, the caller's count of responses with
`couple_id IS NULL`, alongside `couple` and `partner`, so the client can show
"N sealed answers" before and while waiting to pair. `POST /v1/responses` also
returns `sealed_count` in its response body for a solo submission (its `match`
is always `null`).
### Claim on pair
`PostgresCoupleRepository.join()` (`apps/api/src/domains/couples/repository.ts`)
is the only place membership grows from one member to two. In the same
transaction that adds the second member, it:
1. Reassigns every sealed answer (`couple_id IS NULL`) belonging to either
member to the new couple.
2. For every question where both members now have an answer, computes the
match type with the same rule `calculateMatchType` uses for live answers
and upserts into `matches` (respecting the `(couple_id, question_id)`
uniqueness), so a couple's history of solo answers surfaces as matches the
moment they pair, not only for answers given after pairing.
A user who re-pairs after their previous couple was deleted only has
`couple_id IS NULL` answers left to claim (the old couple's responses were
cascade-deleted with it), which this handles the same way as first-time
pairing.
### Push nudge escalation
The unpaired push reminder (`apps/api/src/domains/operations/repository.ts`,
`produce()`) reads each eligible recipient's sealed count and leads with it
once it is greater than zero: "You have N sealed answers waiting. Invite your
partner to unlock what you said about them." Recipients with no sealed
answers yet see the original invite-code copy. The existing 3-day cadence and
18:00-local gate are unchanged.
## Edge Functions
### `manage-couple` (POST)
+13 -1
View File
@@ -61,7 +61,7 @@ Create the ignored root `.env.local` with the public Auth settings for the
designated non-production Supabase project (`itbzhrvlgvdmzbnhzhyx`). Set
`SUPABASE_AUTH_URL` (or `EXPO_PUBLIC_SUPABASE_URL`) and
`EXPO_PUBLIC_SUPABASE_ANON_KEY`; the launcher uses the URL for the API and both
values for Expo. It accepts only this designated hosted project or loopback Auth,
values for Expo and the Admin login. It accepts only this designated hosted project or loopback Auth,
and explicitly rejects the production project and every other hosted project.
Environment variables exported in the shell take precedence over `.env.local`.
The launcher treats `.env.local` as data, never shell code: use unquoted
@@ -115,3 +115,15 @@ without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated
standalone-API flow and must fail closed unless the non-production fixture above
is explicitly supplied. Neither local nor E2E runs may use the production Auth
project, production subscriber, or production application database.
Run browser E2E through the same launcher:
```bash
npm run verify:e2e
```
It starts the stack when needed, waits for API, web, admin, and MCP endpoints,
and derives Playwright URLs from the configured `SAUCI_*_PORT` values. Set
`SAUCI_E2E_REUSE=true` only to reuse a healthy stack managed with the same
configuration. Browser product-data fixtures are direct loopback PostgreSQL
fixtures that prove the standalone API result; they never use local Supabase.
+1
View File
@@ -23,6 +23,7 @@ historical notes. Agents should load only the section relevant to their task.
- [Subscriptions](subscription-system.md)
- [Redemption codes](product/redemption.md)
- [Mobile release process](releasing.md)
- [Mobile over-the-air updates](mobile-ota.md)
- [Dares loop](dares-loop.md)
- [Couples quiz](quiz.md)
+265
View File
@@ -0,0 +1,265 @@
# Mobile over-the-air updates
Sauci ships JavaScript-only mobile changes over the air with `expo-updates`
against a self-hosted XPREM update server (formerly `expo-open-ota`) at
`https://ota.apps.jbrunton.co.uk`. XPREM speaks the official Expo Updates
protocol, so the client is stock `expo-updates`; only the endpoint, the request
headers, and the code-signing certificate are Sauci specific.
Store releases still follow [Mobile release process](releasing.md). OTA never
replaces a store build; it only refreshes the JavaScript bundle and assets of an
already installed build.
## Status
The client and checked-in native projects are wired with the real server values
(app id `9a66f3c2-fda7-416f-87e2-6db833b32e9d` and the Sauci code-signing
certificate, SHA-256 fingerprint beginning `DF:C9:17:A9`). A new store build is
required before updates can flow; see [Server registration](#server-registration).
## How it works
1. A build embeds a manifest URL, a release channel, an app id, and a public
code-signing certificate.
2. On launch the app requests `https://ota.apps.jbrunton.co.uk/manifest`, sending
`expo-channel-name`, `expo-app-id`, `xprem-branch`, and the standard
`expo-runtime-version` and `expo-platform` headers.
3. XPREM resolves the channel to a branch, finds the newest update published for
that branch and runtime version, and returns a signed manifest.
4. The client verifies the signature against the embedded certificate, downloads
the bundle, and launches it on the next start.
### Branches and channels
XPREM separates storage from delivery. A **branch** groups updates for a release
line. A **release channel** is bound to a build at build time and points at a
branch. Repointing a channel at a different branch promotes or reverts an update
without a rebuild.
| EAS build profile | `RELEASE_CHANNEL` | Publish branch |
| --- | --- | --- |
| `development` | `development` | `development` |
| `preview` | `staging` | `staging` |
| `production` | `production` | `production` |
Channels and their branch mappings are created in the XPREM dashboard under
Channels. The client cannot create them.
### Runtime version
`apps/mobile/app.config.js` sets an explicit runtime string:
```js
runtimeVersion: '1.0.7'
```
The release version script updates this string with `expo.version` and the
checked-in iOS and Android runtime values, so it remains the same value as the
public app version. An update is only offered to builds whose runtime version
matches exactly.
The practical rule: **any change that touches native code, native dependencies,
or the `ios/`/`android/` projects requires a new store build with a bumped
`expo.version`.** Bumping `version` starts a fresh runtime version on the server,
so old builds stop receiving updates that assume the new native surface.
JavaScript, asset, and configuration changes that do not alter the native binary
can ship over the air against the current `version`.
An explicit runtime string is used because EOAS requires a concrete value for
this checked-in bare workflow. The synchronized release script keeps it tied to
the number visible in App Store Connect and Google Play without relying on Expo
to resolve a policy during upload.
## Client configuration
`apps/mobile/app.config.js` is a dynamic Expo config that spreads `app.json` and
layers on the `updates` block. `app.json` remains the static source of truth for
`version`, `ios.buildNumber`, and `android.versionCode`, which EAS rewrites
during a release build.
Resolved configuration:
| Key | Value |
| --- | --- |
| `updates.url` | `https://ota.apps.jbrunton.co.uk/manifest` |
| `updates.enabled` | `true` |
| `updates.checkAutomatically` | `ON_LOAD` |
| `updates.fallbackToCacheTimeout` | `5000` |
| `updates.codeSigningCertificate` | `./certs/certificate.pem` |
| `updates.codeSigningMetadata` | `{ keyid: 'main', alg: 'rsa-v1_5-sha256' }` |
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
| `updates.requestHeaders['xprem-branch']` | `''` |
| `runtimeVersion` | Explicit public version, currently `1.0.7` |
`expo-updates` only sends request headers that were declared at build time, so
`xprem-branch` is declared even though it is empty. Removing it would not disable
branch surfing, it would strip the header from every poll for the life of the
install.
`RELEASE_CHANNEL` is set per profile in `apps/mobile/eas.json`. The config throws
a descriptive error whenever `RELEASE_CHANNEL` is set but the committed
certificate is missing or is the placeholder, so a misconfigured build fails at
config resolution rather than shipping a client that can never validate a
manifest.
### Local development
The dev server serves bundles directly and no signed manifest exists, so disable
code signing when running a dev client:
```bash
cd apps/mobile
DISABLE_CODE_SIGNING=1 npx expo start --dev-client
```
## Code signing
XPREM signs every manifest with an RSA private key held by the server. The app
embeds only the matching public certificate.
- `apps/mobile/certs/certificate.pem` is the public certificate. It is public key
material and is committed.
- `private-key.pem` belongs to the update server only. `apps/mobile/certs/.gitignore`
blocks it. It must never be committed or kept in a developer checkout.
The keypair is generated once, on the machine that configures the server:
```bash
npx eoas generate-certs
```
The certificate can also be downloaded from the XPREM dashboard App Info page for
the Sauci app.
## Native projects
`apps/mobile/ios` and `apps/mobile/android` are committed but generated by Expo
CNG, and `docs/releasing.md` treats `expo prebuild` as a source change rather
than a release step. The checked-in native projects embed the production updates
configuration:
- `ios/Sauci/Supporting/Expo.plist` enables updates and contains the manifest
URL, runtime version, request headers, code-signing metadata, and public
certificate.
- `android/app/src/main/AndroidManifest.xml` enables updates and contains the
same production configuration; `android/app/src/main/res/values/strings.xml`
supplies its resolved runtime version.
The `expo-updates` config plugin wrote those values from the dynamic config as a
reviewed source change. When future changes intentionally affect native config,
regenerate and review the native diff separately:
```bash
cd apps/mobile
RELEASE_CHANNEL=production npx expo prebuild
git diff # retain only reviewed expo-updates native changes
```
A build produced before this native source change will not check for updates.
## Publishing an update
Publishing uses the XPREM CLI, `eoas`, pinned as a dev dependency of
`apps/mobile`. Authentication is an app-scoped API token generated in the XPREM
dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed.
```bash
cd apps/mobile
# Verify the exact export environment without contacting the update server.
npm run ota:preflight:production
# Staging: consumed by builds made with the EAS `preview` profile.
EOO_TOKEN=<token> npm run ota:publish:staging
# Production.
EOO_TOKEN=<token> npm run ota:publish:production
```
The scripts resolve the matching EAS build profile into EOAS's export subprocess.
EOAS sets `EXPO_NO_DOTENV=1`, so invoking `eoas publish` directly does not load
profile values from `eas.json`. The wrapper injects the selected profile's public
configuration, including the required API and hosted Auth values, sets the
matching `RELEASE_CHANNEL`, and fixes the target branch. It does not print
configuration values.
Production public API/Auth values come from the checked-in production profile.
Staging uses the preview API value and requires the designated non-production
Auth URL and anonymous key in the invoking environment; it rejects the production
Auth origin. Run its safe export preflight with:
```bash
EXPO_PUBLIC_SUPABASE_URL=https://itbzhrvlgvdmzbnhzhyx.supabase.co \
EXPO_PUBLIC_SUPABASE_ANON_KEY=<non-production-anon-key> \
npm run ota:preflight:staging
```
`npm run ota:doctor:production` performs EOAS's read-only manifest probes using
the production profile. It does not export or upload an update. Use the staging
doctor command with the same non-production Auth environment as its preflight.
Useful flags to pass through to a publish command:
| Flag | Purpose |
| --- | --- |
| `--platform ios\|android\|all` | Defaults to `all`. |
| `-m "<text>"` | Update description shown in the dashboard. |
| `--rollout-percentage <1-99>` | Progressive rollout to a fraction of devices. |
| `--nonInteractive` | Required in CI. |
`eoas publish` refuses to run against a dirty git working tree. Commit first
rather than reaching for `--disableRepositoryCheck` or `EAS_NO_VCS=1`; the
published update records the commit it came from.
Publish only from a commit that has passed `npm run verify:full`. An OTA update
reaches installed devices without store review, so it carries the same risk as a
production deploy and none of the review latency.
### Verifying a published update
```bash
curl -sD - "https://ota.apps.jbrunton.co.uk/manifest" \
-H "expo-app-id: <xprem-app-id>" \
-H "expo-channel-name: production" \
-H "expo-runtime-version: 1.0.7" \
-H "expo-platform: ios"
```
## Rollback
Two mechanisms, in order of preference:
1. **Repoint the channel.** In the XPREM dashboard, map the affected channel to a
branch holding the last known good update. This takes effect at each app's
next update check and needs no rebuild and no republish.
2. **Roll back from the CLI.**
```bash
cd apps/mobile
EOO_TOKEN=<token> npm run ota:rollback
```
`eoas rollback` publishes a rollback directive for a branch and runtime
version, sending clients back to the bundle embedded in the store build.
`eoas republish` can instead re-serve a specific earlier update.
A bad update that prevents the app from starting cannot be fixed over the air on
devices that never complete a launch. Keep `fallbackToCacheTimeout` short and
treat a startup-path change as store-release risk, not OTA risk.
## Server registration
The Sauci app is registered on the XPREM server; the deployment itself is
documented in the homelab repo (`unraid/docs/xprem-ota.md`).
| Value | State |
| --- | --- |
| App id | `9a66f3c2-fda7-416f-87e2-6db833b32e9d`, hardcoded in `apps/mobile/app.config.js` (non-secret) |
| Code-signing certificate | Committed at `apps/mobile/certs/certificate.pem` (public half; CN=Sauci, valid to 2036, SHA-256 fingerprint `DF:C9:17:A9:85:02:DD:EB:86:06:2B:64:8D:73:D2:8C:A2:AA:29:F0:AC:62:A8:76:62:A1:6B:A2:42:B0:3D:71`) |
| Signing private key | Never leaves the server: keys mode `database`, sealed in the server's Postgres under its master key |
| Publish token | `XPREM_SAUCI_API_KEY` in the homelab repo `.env`; export as `EOO_TOKEN` when publishing |
| Channels | `staging` and `production` created server-side, mapped 1:1 to branches of the same names. `development` intentionally has no server channel — dev clients run with `DISABLE_CODE_SIGNING=1` against the packager |
Remaining before updates flow: cut a new store build from this checked-in native
source. Only builds produced after that point can receive updates.
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more