mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc6f8aa1a | ||
|
|
02a7349899 | ||
|
|
8f70dbcc00 | ||
|
|
ac80d6b614 | ||
|
|
056bb62d04 | ||
|
|
80ec1663f9 | ||
|
|
8ff05c050b | ||
|
|
549419892d | ||
|
|
a46b8b95e6 | ||
|
|
e54f6d31ed | ||
|
|
267f3c3cf9 | ||
|
|
02aa6e4a75 | ||
|
|
cf19d23d8a | ||
|
|
db5980c9bd | ||
|
|
2664791233 | ||
|
|
f37673cad7 | ||
|
|
0ada812294 | ||
|
|
f4db567267 | ||
|
|
d298a33656 | ||
|
|
af4e1d0cf6 | ||
|
|
9e6e4612f5 | ||
|
|
7d392e756d | ||
|
|
3c08963a70 | ||
|
|
d86d57d5c2 | ||
|
|
57501230cd | ||
|
|
986d0b21cb | ||
|
|
504eddaf68 | ||
|
|
f52b8536be | ||
|
|
09255da7db | ||
|
|
8e83cc0d85 | ||
|
|
8955e49aae | ||
|
|
04822043ae | ||
|
|
eab73e6314 | ||
|
|
fd8981fa28 | ||
|
|
25b7c6238e | ||
|
|
77f2718fdd | ||
|
|
f22d4a3649 | ||
|
|
45d24911fb | ||
|
|
f38b6f14fa | ||
|
|
74c1acfe2d | ||
|
|
23db4ed951 | ||
|
|
ccd3f03437 | ||
|
|
6523ddeb1c | ||
|
|
4f9950f21b | ||
|
|
d48b44baa8 | ||
|
|
5564f9bbab | ||
|
|
c972b1c236 | ||
|
|
30aa7bc386 | ||
|
|
8bbb0e8cea | ||
|
|
fbdb2a8fff | ||
|
|
bdfe8b7171 | ||
|
|
2cbe9e9c55 | ||
|
|
8d9a3f4f1c | ||
|
|
476e4c845f | ||
|
|
3d53d001b7 | ||
|
|
8658fc38fe | ||
|
|
24de9af4cd | ||
|
|
5e581efe51 | ||
|
|
12c6b8bcc9 | ||
|
|
599be22eb1 | ||
|
|
9514835bf2 | ||
|
|
65bd1bbd6b | ||
|
|
e0813ba5a3 | ||
|
|
f7c686d5cf | ||
|
|
314664ecd6 | ||
|
|
11f89b1345 | ||
|
|
2481956c11 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
fd38b303f0 | ||
|
|
387b242aea |
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-30-ios-extension-version-verification
|
||||||
|
date: 2026-08-30
|
||||||
|
status: promoted
|
||||||
|
scope: iOS release artifact verification
|
||||||
|
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA
|
||||||
|
destination: .codex/skills/release-mobile/SKILL.md
|
||||||
|
review_after: 2027-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
The signed iOS build logged that the widget extension build number 44 did not
|
||||||
|
match the containing app build number 45, even though export completed.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Build iOS version 1.0.6 with EAS auto-increment from build 44 to 45 and retain the
|
||||||
|
archive log. Extract the resulting IPA with `ditto`, then read the containing app
|
||||||
|
and every packaged extension `Info.plist` with `PlistBuddy`.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
The archive warning described an intermediate state and was not authoritative
|
||||||
|
for the exported package. Direct IPA inspection showed both the app and
|
||||||
|
`LiveDrawWidget.appex` at version 1.0.6 build 45.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
For every iOS release, inspect version and build values in the exported app and
|
||||||
|
all packaged extensions. Reject or rebuild only when the final IPA values differ;
|
||||||
|
do not accept or reject a release from archive warnings alone.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
|
||||||
|
both packaged plists. `codesign --verify --deep --strict` passed for the app, and
|
||||||
|
the verified IPA SHA-256 was
|
||||||
|
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
name = "implementation"
|
name = "implementation"
|
||||||
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
|
description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
|
||||||
model = "gpt-5.6-terra"
|
model = "gpt-5.6-terra"
|
||||||
developer_instructions = """
|
developer_instructions = """
|
||||||
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||||
skill. Implement only the delegated objective and preserve unrelated work. Update
|
skill. Own the continuous execution unit: perform scoped discovery,
|
||||||
maintained contract documentation and add focused tests when the contract changes.
|
implementation, focused tests, and iteration in this handoff. Implement only the
|
||||||
Run the required focused checks and return changed files, exact results,
|
delegated objective and preserve unrelated work. Update maintained contract
|
||||||
assumptions, and blockers. Stop for missing authority or a materially ambiguous
|
documentation and add focused tests when the contract changes. Run the required
|
||||||
product choice. Do not expand scope or perform external, destructive, production,
|
focused checks and return changed files, exact results, assumptions, and blockers.
|
||||||
release, or provider writes unless the task explicitly authorizes that action.
|
Stop for missing authority or a materially ambiguous product choice. Do not expand
|
||||||
|
scope or perform external, destructive, production, release, or provider writes
|
||||||
|
unless the task explicitly authorizes that action.
|
||||||
"""
|
"""
|
||||||
@@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services.
|
|||||||
|
|
||||||
| Service | Port |
|
| Service | Port |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| web | 3000 |
|
| PostgreSQL | 54320 |
|
||||||
| admin | 3001 |
|
| API | 3003 |
|
||||||
| MCP | 3002 |
|
| web | 3000 (overridable with `SAUCI_WEB_PORT`) |
|
||||||
|
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
|
||||||
|
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
|
||||||
| Expo Metro | 8081 |
|
| Expo Metro | 8081 |
|
||||||
| Supabase API / DB / Studio | 54321 / 54322 / 54323 |
|
|
||||||
|
|
||||||
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and
|
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
|
||||||
the repository Supabase CLI dependency.
|
and the configured hosted Auth credentials where an authenticated client needs them.
|
||||||
|
|
||||||
- `scripts/dev-local.sh up` — idempotently start infra and services.
|
- `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
|
||||||
- `status`, `logs <service>`, `restart <service>`, `attach` — inspect.
|
- `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
|
||||||
- `reset` — local-only database reset plus deterministic fixtures.
|
- `reset`: local-only database reset plus deterministic fixtures.
|
||||||
- `ios` / `android` — launch the native app against local Supabase.
|
- `e2e`: start or safely reuse the local stack, validate local endpoints, and
|
||||||
- `down`; `down --all` — stop services, optionally infrastructure.
|
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
|
Hosted Supabase provides Auth only. Local E2E never starts or writes to a
|
||||||
anything. If a tmux window exited, read its logs before restarting it.
|
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.
|
||||||
@@ -36,12 +36,17 @@ Require all of the following:
|
|||||||
- a named branch, normally `main` for a production release;
|
- a named branch, normally `main` for a production release;
|
||||||
- a clean starting worktree with `HEAD` equal to its fetched upstream;
|
- 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;
|
- 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
|
- consistent public and OTA runtime versions across `app.json`, explicit
|
||||||
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
|
`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;
|
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||||
- the configured production API/Auth/RevenueCat environment, without printing
|
- the configured production API/Auth/RevenueCat environment, without printing
|
||||||
secret values.
|
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
|
Do not switch branches, discard work, repair credentials, push, or weaken branch
|
||||||
protection merely to pass preflight. Stop with the exact blocker. Never run
|
protection merely to pass preflight. Stop with the exact blocker. Never run
|
||||||
`expo prebuild` as part of this workflow.
|
`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 repoRoot = path.resolve(scriptDir, "../../../..");
|
||||||
const files = {
|
const files = {
|
||||||
app: path.join(repoRoot, "apps/mobile/app.json"),
|
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"),
|
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"),
|
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]+)$/;
|
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
|
||||||
@@ -25,10 +30,19 @@ function parseVersion(value, label) {
|
|||||||
return match.slice(1).map(Number);
|
return match.slice(1).map(Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writePreservingFinalNewline(file, contents, previous) {
|
||||||
|
fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
function readState() {
|
function readState() {
|
||||||
const appRaw = fs.readFileSync(files.app, "utf8");
|
const appRaw = fs.readFileSync(files.app, "utf8");
|
||||||
|
const appConfigRaw = fs.readFileSync(files.appConfig, "utf8");
|
||||||
const androidRaw = fs.readFileSync(files.android, "utf8");
|
const androidRaw = fs.readFileSync(files.android, "utf8");
|
||||||
|
const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8");
|
||||||
const iosRaw = fs.readFileSync(files.ios, "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 appVersion = JSON.parse(appRaw).expo?.version;
|
||||||
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
||||||
(match) => match[1],
|
(match) => match[1],
|
||||||
@@ -36,18 +50,63 @@ function readState() {
|
|||||||
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
|
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
|
||||||
(match) => match[1].trim(),
|
(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 (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
|
||||||
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
||||||
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
|
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}`));
|
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
|
||||||
if (new Set(versions).size !== 1) {
|
if (new Set(versions).size !== 1) {
|
||||||
fail(`public versions disagree: ${versions.join(", ")}`);
|
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) {
|
function nextVersion(current, bump) {
|
||||||
@@ -69,7 +128,7 @@ if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
|
|||||||
|
|
||||||
const state = readState();
|
const state = readState();
|
||||||
if (check) {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,12 +153,37 @@ const androidRaw = state.androidRaw.replace(
|
|||||||
/(\bversionName\s+["'])([^"']+)(["'])/,
|
/(\bversionName\s+["'])([^"']+)(["'])/,
|
||||||
`$1${desired}$3`,
|
`$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(
|
const iosRaw = state.iosRaw.replace(
|
||||||
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
||||||
`$1${desired}$3`,
|
`$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);
|
writePreservingFinalNewline(files.app, appRaw, state.appRaw);
|
||||||
fs.writeFileSync(files.android, androidRaw);
|
writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw);
|
||||||
fs.writeFileSync(files.ios, iosRaw);
|
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}`);
|
console.log(`updated public version ${state.current} -> ${desired}`);
|
||||||
@@ -54,5 +54,9 @@ additional TestFlight distribution requires another fresh authorization.
|
|||||||
|
|
||||||
Preserve platform signing and local EAS versioning. After a build, verify that
|
Preserve platform signing and local EAS versioning. After a build, verify that
|
||||||
tracked changes are limited to the expected version metadata, then independently
|
tracked changes are limited to the expected version metadata, then independently
|
||||||
read the artifact version and signature. Report the artifact, version/build
|
read the artifact version and signature. For an iOS IPA, inspect every packaged
|
||||||
number, upload result when authorized, and store read-back state.
|
app extension as well as the containing app and require matching
|
||||||
|
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
|
||||||
|
warnings as diagnostic evidence, not the final artifact state: decide whether to
|
||||||
|
reject or rebuild only after reading the exported IPA itself. Report the artifact,
|
||||||
|
version/build number, upload result when authorized, and store read-back state.
|
||||||
@@ -5,25 +5,34 @@ description: Prove a completed Sauci engineering task works before a PR. Use whe
|
|||||||
|
|
||||||
# Verify Sauci work
|
# Verify Sauci work
|
||||||
|
|
||||||
Run locally on a non-default branch with the implementation committed.
|
Run locally on a non-default branch with the implementation committed. Use an
|
||||||
|
independent verifier for user-facing, cross-boundary, or high-risk runtime
|
||||||
|
changes. Documentation, mechanical configuration, isolated tests, and low-risk
|
||||||
|
package-local changes may use focused deterministic checks without a verifier.
|
||||||
|
|
||||||
1. Start the stack once with `scripts/dev-local.sh up`; reset fixtures first when
|
1. Package-local deterministic checks do not need a running stack. For runtime
|
||||||
the task changes durable state. Web is `http://127.0.0.1:3000`, admin is
|
verification, reuse a compatible running local stack when available; otherwise
|
||||||
`http://127.0.0.1:3001`, and MCP health is `http://127.0.0.1:3002/health`.
|
start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
|
||||||
2. Give a fresh read-only verifier the acceptance criteria. It drives browser
|
first when the task changes durable state. Web is `http://127.0.0.1:3000`,
|
||||||
|
admin is `http://127.0.0.1:3001`, and MCP health is
|
||||||
|
`http://127.0.0.1:3002/health`.
|
||||||
|
2. When independent runtime verification is required, give a fresh read-only
|
||||||
|
verifier the acceptance criteria. It drives browser
|
||||||
surfaces with the repository browser capability, native surfaces with Maestro
|
surfaces with the repository browser capability, native surfaces with Maestro
|
||||||
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
||||||
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
||||||
3. The verifier writes screenshots, traces, video, or response evidence under
|
3. The verifier writes screenshots, traces, video, or response evidence under
|
||||||
`evidence/` and returns only expected, observed, evidence paths, and
|
`evidence/` and returns only expected, observed, evidence paths, and
|
||||||
`works|broken`. It never edits code.
|
`works|broken`. It never edits code.
|
||||||
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and
|
4. If independent verification is broken, fix and use a fresh verifier. Stop
|
||||||
report the unresolved evidence.
|
after three failed rounds and report the unresolved evidence.
|
||||||
5. After the task works, run `npm run verify:fast`, affected E2E, and
|
5. Run focused checks for the affected change. Run affected E2E when the runtime
|
||||||
`npm run verify:full` when local Supabase/Deno are available. Never weaken an
|
path requires it. Run `npm run verify:full` when its prerequisites are
|
||||||
assertion just to pass.
|
available; it already runs `npm run verify:fast`, so do not run both in
|
||||||
|
sequence. Never weaken an assertion just to pass.
|
||||||
6. Open a PR only after both task verification and regression checks pass. Upload
|
6. Open a PR only after both task verification and regression checks pass. Upload
|
||||||
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
||||||
the screenshot plus a video/trace link in the PR body.
|
the screenshot plus a video/trace link in the PR body.
|
||||||
|
|
||||||
A green test suite without an independently observed task outcome is incomplete.
|
A green test suite without an independently observed outcome is incomplete when
|
||||||
|
independent runtime verification is required.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
.next
|
||||||
|
**/.next
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
@@ -23,11 +25,15 @@ apps/mcp
|
|||||||
apps/mobile
|
apps/mobile
|
||||||
apps/supabase
|
apps/supabase
|
||||||
apps/web
|
apps/web
|
||||||
|
!apps/web
|
||||||
|
!apps/web/**
|
||||||
e2e
|
e2e
|
||||||
evidence
|
evidence
|
||||||
etc
|
etc
|
||||||
infra
|
infra
|
||||||
scripts
|
scripts
|
||||||
|
!scripts
|
||||||
|
!scripts/fix-hoisting.js
|
||||||
terraform
|
terraform
|
||||||
.agents
|
.agents
|
||||||
.claude
|
.claude
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ e2e/playwright-report/
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
|
!apps/mobile/certs/certificate.pem
|
||||||
.cache/
|
.cache/
|
||||||
.temp/
|
.temp/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|||||||
@@ -25,11 +25,21 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
and prove the relevant compatibility contracts still hold. Preserve deployed
|
and prove the relevant compatibility contracts still hold. Preserve deployed
|
||||||
interfaces and supported client behavior; repository checks alone do not
|
interfaces and supported client behavior; repository checks alone do not
|
||||||
prove a live change is safe.
|
prove a live change is safe.
|
||||||
10. Scope work to the task. Route bounded implementation through the project
|
10. The Sol primary agent owns scope, risk classification, acceptance criteria,
|
||||||
`implementation` agent when delegation is useful; the primary agent retains
|
progress steering, final diff and evidence review, and the final answer.
|
||||||
acceptance, review, and final verification. Do not delegate trivial edits.
|
Delegate every substantive repository task to a project agent. Each
|
||||||
|
substantive implementation must use one Terra `implementation` agent as a
|
||||||
|
coherent execution unit, normally combining scoped discovery, implementation,
|
||||||
|
focused tests, and iteration. "Substantive" includes repository exploration,
|
||||||
|
multi-file reasoning, implementation, test execution, or contract/risk
|
||||||
|
analysis. Add a `repo_scout`, reviewer, or verifier only for separable work or
|
||||||
|
the triggers in `docs/agents/routing.md`; do not duplicate the implementation
|
||||||
|
agent's detailed exploration or checks without a concrete reason. Skip
|
||||||
|
delegation only for trivial conversation, a single obvious lookup, or a
|
||||||
|
mechanical one-line edit whose coordination cost exceeds the work.
|
||||||
11. Use high-effort reviewers only for the risk triggers in
|
11. Use high-effort reviewers only for the risk triggers in
|
||||||
`docs/agents/routing.md`.
|
`docs/agents/routing.md`.
|
||||||
|
12. Agents must not use em dash characters in text they add to this repository.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
|
|||||||
import { TagsPage } from '@/pages/content/TagsPage';
|
import { TagsPage } from '@/pages/content/TagsPage';
|
||||||
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
||||||
import { DaresPage } from '@/pages/content/DaresPage';
|
import { DaresPage } from '@/pages/content/DaresPage';
|
||||||
|
import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage';
|
||||||
import { UsersPage } from '@/pages/users/UsersPage';
|
import { UsersPage } from '@/pages/users/UsersPage';
|
||||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||||
|
import { CouplesPage } from '@/pages/users/CouplesPage';
|
||||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||||
@@ -20,6 +22,8 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
|||||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||||
|
import { SentDaresPage } from '@/pages/activity/SentDaresPage';
|
||||||
|
import { LiveDrawSessionsPage } from '@/pages/activity/LiveDrawSessionsPage';
|
||||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||||
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
||||||
@@ -108,6 +112,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/quiz-questions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||||
|
<QuizQuestionsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* User management */}
|
{/* User management */}
|
||||||
<Route
|
<Route
|
||||||
@@ -142,6 +154,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/couples"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||||
|
<CouplesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/activity"
|
path="/activity"
|
||||||
element={
|
element={
|
||||||
@@ -150,6 +170,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</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 */}
|
{/* System */}
|
||||||
<Route
|
<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,
|
PieChart,
|
||||||
Tags,
|
Tags,
|
||||||
Zap,
|
Zap,
|
||||||
|
HelpCircle,
|
||||||
|
Heart,
|
||||||
|
Swords,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -68,6 +72,12 @@ const contentNav: NavItem[] = [
|
|||||||
icon: <Tags className="h-5 w-5" />,
|
icon: <Tags className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
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[] = [
|
const usersNav: NavItem[] = [
|
||||||
@@ -83,6 +93,24 @@ const usersNav: NavItem[] = [
|
|||||||
icon: <Activity className="h-5 w-5" />,
|
icon: <Activity className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
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',
|
label: 'Usage Insights',
|
||||||
href: '/usage-insights',
|
href: '/usage-insights',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const AUDITED_TABLES = [
|
|||||||
'categories',
|
'categories',
|
||||||
'dare_packs',
|
'dare_packs',
|
||||||
'dares',
|
'dares',
|
||||||
|
'quiz_questions',
|
||||||
'profiles',
|
'profiles',
|
||||||
'couples',
|
'couples',
|
||||||
'responses',
|
'responses',
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}%` };
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { toast } from 'sonner';
|
||||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
import type { ContentReviewStatus } from '@sauci/shared';
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
import { parseIntensityRange } from '@/lib/dareIntensity';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -54,6 +55,9 @@ interface DarePack {
|
|||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
|
min_intensity: number | null;
|
||||||
|
max_intensity: number | null;
|
||||||
|
avg_intensity: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
dare_count?: number;
|
dare_count?: number;
|
||||||
content_status?: ContentReviewStatus | null;
|
content_status?: ContentReviewStatus | null;
|
||||||
@@ -68,6 +72,8 @@ interface DarePackFormData {
|
|||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
category_id: string;
|
category_id: string;
|
||||||
|
min_intensity: string;
|
||||||
|
max_intensity: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -96,6 +102,8 @@ export function DarePacksPage() {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: categoryId ?? '',
|
category_id: categoryId ?? '',
|
||||||
|
min_intensity: '',
|
||||||
|
max_intensity: '',
|
||||||
},
|
},
|
||||||
(pack) => ({
|
(pack) => ({
|
||||||
name: pack.name,
|
name: pack.name,
|
||||||
@@ -105,6 +113,8 @@ export function DarePacksPage() {
|
|||||||
is_public: pack.is_public,
|
is_public: pack.is_public,
|
||||||
is_explicit: pack.is_explicit,
|
is_explicit: pack.is_explicit,
|
||||||
category_id: pack.category_id || '',
|
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;
|
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);
|
form.setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (form.editingItem) {
|
if (form.editingItem) {
|
||||||
@@ -220,6 +238,8 @@ export function DarePacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
|
min_intensity: parsedMin,
|
||||||
|
max_intensity: parsedMax,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Dare pack updated');
|
toast.success('Dare pack updated');
|
||||||
@@ -232,6 +252,8 @@ export function DarePacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
|
min_intensity: parsedMin,
|
||||||
|
max_intensity: parsedMax,
|
||||||
sort_order: totalCount,
|
sort_order: totalCount,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -410,6 +432,36 @@ export function DarePacksPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>Premium</Label>
|
<Label>Premium</Label>
|
||||||
@@ -572,6 +624,20 @@ export function DarePacksPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { toast } from 'sonner';
|
|||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
import type { ContentReviewStatus } from '@sauci/shared';
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
import { clampDareIntensity, MAX_DARE_INTENSITY, MIN_DARE_INTENSITY } from '@/lib/dareIntensity';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -102,9 +103,11 @@ export function DaresPage() {
|
|||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
text: string;
|
text: string;
|
||||||
|
intensity: string;
|
||||||
suggested_duration_hours: string;
|
suggested_duration_hours: string;
|
||||||
}>({
|
}>({
|
||||||
text: '',
|
text: '',
|
||||||
|
intensity: '1',
|
||||||
suggested_duration_hours: '',
|
suggested_duration_hours: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,7 +169,7 @@ export function DaresPage() {
|
|||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingDare(null);
|
setEditingDare(null);
|
||||||
setFormData({ text: '', suggested_duration_hours: '' });
|
setFormData({ text: '', intensity: '1', suggested_duration_hours: '' });
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ export function DaresPage() {
|
|||||||
setEditingDare(dare);
|
setEditingDare(dare);
|
||||||
setFormData({
|
setFormData({
|
||||||
text: dare.text,
|
text: dare.text,
|
||||||
|
intensity: String(dare.intensity ?? 1),
|
||||||
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -185,10 +189,13 @@ export function DaresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intensity = clampDareIntensity(formData.intensity);
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const dareData = {
|
const dareData = {
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
|
intensity,
|
||||||
suggested_duration_hours: formData.suggested_duration_hours
|
suggested_duration_hours: formData.suggested_duration_hours
|
||||||
? parseInt(formData.suggested_duration_hours, 10)
|
? parseInt(formData.suggested_duration_hours, 10)
|
||||||
: null,
|
: null,
|
||||||
@@ -338,6 +345,31 @@ export function DaresPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label>Suggested Duration</Label>
|
<Label>Suggested Duration</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -444,6 +476,7 @@ export function DaresPage() {
|
|||||||
<TableHead className="w-12">#</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Dare</TableHead>
|
<TableHead>Dare</TableHead>
|
||||||
<TableHead className="w-40">Catalogue</TableHead>
|
<TableHead className="w-40">Catalogue</TableHead>
|
||||||
|
<TableHead className="w-20">Intensity</TableHead>
|
||||||
<TableHead className="w-24">Duration</TableHead>
|
<TableHead className="w-24">Duration</TableHead>
|
||||||
<TableHead className="w-24">Actions</TableHead>
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -478,6 +511,11 @@ export function DaresPage() {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{dare.intensity}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{dare.suggested_duration_hours ? (
|
{dare.suggested_duration_hours ? (
|
||||||
<Badge variant="outline" className="text-xs">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useParams, Link } from 'react-router-dom';
|
||||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
@@ -37,12 +37,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 { format } from 'date-fns';
|
||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
import type { AnswerType, QuestionType } from '@sauci/shared';
|
import type { AnswerType, QuestionType } from '@sauci/shared';
|
||||||
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
|
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
|
||||||
import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
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 UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
@@ -61,6 +63,7 @@ interface Profile {
|
|||||||
usage_reason: UsageReason | null;
|
usage_reason: UsageReason | null;
|
||||||
hide_nsfw: boolean | null;
|
hide_nsfw: boolean | null;
|
||||||
onboarding_completed: boolean | null;
|
onboarding_completed: boolean | null;
|
||||||
|
timezone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||||
@@ -158,11 +161,72 @@ interface CoupleStreak {
|
|||||||
current_streak: number;
|
current_streak: number;
|
||||||
longest_streak: number;
|
longest_streak: number;
|
||||||
last_active_date: string | null;
|
last_active_date: string | null;
|
||||||
|
last_completed_date: string | null;
|
||||||
last_milestone_celebrated: number | null;
|
last_milestone_celebrated: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 = {
|
const matchTypeLabels = {
|
||||||
yes_yes: 'Both Yes!',
|
yes_yes: 'Both Yes!',
|
||||||
yes_maybe: 'Yes + Maybe',
|
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" />;
|
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 }) {
|
function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -322,56 +368,6 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
|||||||
};
|
};
|
||||||
}, [coupleId]);
|
}, [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 (loading) return <Skeleton className="h-64 w-full" />;
|
||||||
if (error) return <div className="text-sm text-red-500">Failed to load drawing: {error}</div>;
|
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>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-xl overflow-hidden border" style={{ background: CANVAS_BACKGROUND }}>
|
<StrokeCanvas strokes={strokes} />
|
||||||
<canvas
|
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} - updates in real-time</p>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -424,6 +415,10 @@ export function UserDetailPage() {
|
|||||||
const [mediaPage, setMediaPage] = useState(1);
|
const [mediaPage, setMediaPage] = useState(1);
|
||||||
const [mediaPageSize, setMediaPageSize] = useState(12);
|
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||||
const [mediaTotal, setMediaTotal] = useState(0);
|
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 [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||||
@@ -492,6 +487,39 @@ export function UserDetailPage() {
|
|||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
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) {
|
if (profileData?.couple_id) {
|
||||||
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
@@ -503,9 +531,20 @@ export function UserDetailPage() {
|
|||||||
.eq('couple_id', profileData.couple_id)
|
.eq('couple_id', profileData.couple_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
setStreak(streakData);
|
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 {
|
} else {
|
||||||
setPartner(null);
|
setPartner(null);
|
||||||
setStreak(null);
|
setStreak(null);
|
||||||
|
setQuizSessions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
@@ -789,12 +828,59 @@ export function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</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 */}
|
{/* 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">
|
<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-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 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.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 ${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>
|
</div></CardContent></Card>
|
||||||
{/* Streak Info */}
|
{/* Streak Info */}
|
||||||
{profile.couple_id && streak && (
|
{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>
|
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||||
</div>
|
</div>
|
||||||
</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="flex items-start gap-3">
|
||||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
<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" />
|
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
@@ -848,6 +944,28 @@ export function UserDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Feature Opt-Ins */}
|
||||||
{featureInterests.length > 0 && (
|
{featureInterests.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -872,6 +990,83 @@ export function UserDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export default defineConfig({
|
|||||||
host: true,
|
host: true,
|
||||||
port: 3001,
|
port: 3001,
|
||||||
allowedHosts: ['manage.sauci.app'],
|
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: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
interval: 100,
|
interval: 100,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
|
||||||
|
-- answers every question twice, once about themselves and once guessing their
|
||||||
|
-- partner, and the session only scores once both partners have a complete set.
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_questions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"prompt_self" text NOT NULL UNIQUE,
|
||||||
|
"prompt_guess" text NOT NULL,
|
||||||
|
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
|
||||||
|
"sort_order" integer NOT NULL DEFAULT 0,
|
||||||
|
"is_active" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
|
||||||
|
"question_ids" uuid[] NOT NULL,
|
||||||
|
"score_percent" integer,
|
||||||
|
"completed_at" timestamptz,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
|
||||||
|
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
|
||||||
|
ON "quiz_sessions" ("couple_id", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_answers" (
|
||||||
|
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"question_id" uuid NOT NULL,
|
||||||
|
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
|
||||||
|
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("session_id", "user_id", "question_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||||
|
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
|
||||||
|
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
|
||||||
|
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
|
||||||
|
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
|
||||||
|
('How do you most like to receive love?', 'How does your partner most like to receive love?',
|
||||||
|
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
|
||||||
|
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
|
||||||
|
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
|
||||||
|
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
|
||||||
|
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
|
||||||
|
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
|
||||||
|
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
|
||||||
|
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
|
||||||
|
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
|
||||||
|
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
|
||||||
|
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
|
||||||
|
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
|
||||||
|
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
|
||||||
|
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
|
||||||
|
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
|
||||||
|
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
|
||||||
|
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
|
||||||
|
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
|
||||||
|
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
|
||||||
|
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- The seeded quiz pool of 12 questions was small enough that repeat sessions for
|
||||||
|
-- the same couple felt almost identical. Widen the pool so the never-used-first
|
||||||
|
-- selection strategy in the repository has enough fresh material to draw from.
|
||||||
|
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||||
|
('What is your ideal pet?', 'What is your partner''s ideal pet?',
|
||||||
|
ARRAY['A dog', 'A cat', 'Something low-maintenance', 'No pets, thanks'], 130),
|
||||||
|
('What is your go-to coffee or tea order?', 'What is your partner''s go-to coffee or tea order?',
|
||||||
|
ARRAY['Strong and simple', 'Something sweet and milky', 'Herbal or decaf', 'I skip it entirely'], 140),
|
||||||
|
('What feature would your dream house have?', 'What feature would your partner''s dream house have?',
|
||||||
|
ARRAY['A big kitchen', 'A garden or outdoor space', 'A cosy reading nook', 'A home cinema or games room'], 150),
|
||||||
|
('What is your favourite season?', 'What is your partner''s favourite season?',
|
||||||
|
ARRAY['Spring', 'Summer', 'Autumn', 'Winter'], 160),
|
||||||
|
('On a road trip, what role do you take?', 'On a road trip, what role does your partner take?',
|
||||||
|
ARRAY['Driver', 'Navigator', 'Playlist curator', 'Snack manager'], 170),
|
||||||
|
('When a cooking attempt goes wrong, what do you do?', 'When a cooking attempt goes wrong, what does your partner do?',
|
||||||
|
ARRAY['Laugh it off and order in', 'Try to fix it anyway', 'Start again from scratch', 'Blame the recipe'], 180),
|
||||||
|
('What is your worst phone habit?', 'What is your partner''s worst phone habit?',
|
||||||
|
ARRAY['Scrolling too long', 'Never replying quickly', 'Too many notifications on', 'Checking it during conversations'], 190),
|
||||||
|
('What makes your ideal birthday?', 'What makes your partner''s ideal birthday?',
|
||||||
|
ARRAY['A big party with friends', 'A quiet day together', 'A surprise outing', 'Just a nice meal and cake'], 200),
|
||||||
|
('How do you behave during a board game?', 'How does your partner behave during a board game?',
|
||||||
|
ARRAY['Fiercely competitive', 'Relaxed, just for fun', 'Overly strategic', 'Happy to lose as long as it is fun'], 210),
|
||||||
|
('What is your packing style before a trip?', 'What is your partner''s packing style before a trip?',
|
||||||
|
ARRAY['Packed days in advance', 'Careful list-maker', 'Last-minute scramble', 'Overpacks just in case'], 220),
|
||||||
|
('How well do you handle scary movies?', 'How well does your partner handle scary movies?',
|
||||||
|
ARRAY['Loves them, no problem', 'Watches through fingers', 'Needs the lights on after', 'Avoids them completely'], 230),
|
||||||
|
('What is your dance floor style?', 'What is your partner''s dance floor style?',
|
||||||
|
ARRAY['First one out there', 'Needs a drink first', 'Sways from the sidelines', 'Stays firmly seated'], 240),
|
||||||
|
('What would you do with a free hour to yourself?', 'What would your partner do with a free hour to themselves?',
|
||||||
|
ARRAY['Nap or rest', 'Read or watch something', 'Go for a walk or exercise', 'Tidy up or get things done'], 250),
|
||||||
|
('Which type of celebrity would you want as a dinner guest?', 'Which type of celebrity would your partner want as a dinner guest?',
|
||||||
|
ARRAY['A comedian', 'A musician', 'An athlete', 'An author or thinker'], 260),
|
||||||
|
('What childhood dream job says the most about you?', 'What childhood dream job says the most about your partner?',
|
||||||
|
ARRAY['Astronaut or explorer', 'Performer or artist', 'Something helping others', 'Something running the show'], 270),
|
||||||
|
('How would you describe your tidiness style?', 'How would you describe your partner''s tidiness style?',
|
||||||
|
ARRAY['Everything has its place', 'Tidy in bursts', 'Organised chaos', 'Relaxed about mess'], 280),
|
||||||
|
('Are you more spontaneous or a planner?', 'Is your partner more spontaneous or a planner?',
|
||||||
|
ARRAY['Fully spontaneous', 'Mostly spontaneous', 'Mostly a planner', 'Fully a planner'], 290),
|
||||||
|
('What genre do you rewatch for comfort?', 'What genre does your partner rewatch for comfort?',
|
||||||
|
ARRAY['Sitcoms', 'Feel-good films', 'Fantasy or sci-fi', 'Reality shows'], 300),
|
||||||
|
('How would you describe your social battery?', 'How would you describe your partner''s social battery?',
|
||||||
|
ARRAY['Energised by people', 'Fine in small doses', 'Drained quickly', 'Depends entirely on the day'], 310),
|
||||||
|
('What is your gift-giving style?', 'What is your partner''s gift-giving style?',
|
||||||
|
ARRAY['Thoughtful and personal', 'Practical and useful', 'Big and generous', 'Last-minute but well-meant'], 320),
|
||||||
|
('What kind of breakfast personality do you have?', 'What kind of breakfast personality does your partner have?',
|
||||||
|
ARRAY['Big cooked breakfast', 'Quick and light', 'Coffee only', 'Skips it most days'], 330),
|
||||||
|
('What weather do you prefer?', 'What weather does your partner prefer?',
|
||||||
|
ARRAY['Warm and sunny', 'Cool and crisp', 'Rainy and cosy', 'Snowy'], 340),
|
||||||
|
('If you won the lottery, what would you do first?', 'If your partner won the lottery, what would they do first?',
|
||||||
|
ARRAY['Pay off everything', 'Book a big trip', 'Treat the people close to them', 'Save it and think it over'], 350),
|
||||||
|
('When lost in a new city, what do you do?', 'When lost in a new city, what does your partner do?',
|
||||||
|
ARRAY['Ask a local for help', 'Follow a map or app closely', 'Wander until something looks familiar', 'Stay calm and retrace your steps'], 360)
|
||||||
|
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Optional proof-of-completion for dares. The sender picks a requirement
|
||||||
|
-- ('none', 'photo', 'audio') at send time for both catalogue and custom dares;
|
||||||
|
-- the recipient attaches a media object when submitting.
|
||||||
|
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_type text NOT NULL DEFAULT 'none';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_proof_type_check;
|
||||||
|
ALTER TABLE sent_dares ADD CONSTRAINT sent_dares_proof_type_check
|
||||||
|
CHECK (proof_type IN ('none', 'photo', 'audio'));
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_proof_media_idx ON sent_dares(proof_media_id) WHERE proof_media_id IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE media_objects DROP CONSTRAINT IF EXISTS media_objects_kind_check;
|
||||||
|
ALTER TABLE media_objects ADD CONSTRAINT media_objects_kind_check
|
||||||
|
CHECK (kind IN ('avatar', 'response', 'chat', 'feedback', 'dare_proof'));
|
||||||
@@ -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 $$;
|
||||||
@@ -134,6 +134,34 @@
|
|||||||
"when": 1788081480000,
|
"when": 1788081480000,
|
||||||
"tag": "0018_cutover_source_parity",
|
"tag": "0018_cutover_source_parity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788168000000,
|
||||||
|
"tag": "0019_quiz",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
||||||
import type { ApiRepository } from './db/repository.js';
|
import type { ApiRepository } from './db/repository.js';
|
||||||
@@ -15,6 +16,8 @@ import type { ChatRepository } from './domains/chat/repository.js';
|
|||||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||||
import type { DaresRepository } from './domains/dares/repository.js';
|
import type { DaresRepository } from './domains/dares/repository.js';
|
||||||
import { registerDareRoutes } from './domains/dares/routes.js';
|
import { registerDareRoutes } from './domains/dares/routes.js';
|
||||||
|
import type { QuizRepository } from './domains/quiz/repository.js';
|
||||||
|
import { registerQuizRoutes } from './domains/quiz/routes.js';
|
||||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
import type { CoupleService } from './domains/couples/service.js';
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
import type { PacksRepository } from './domains/packs/repository.js';
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -34,11 +37,13 @@ type Variables = { identity: AuthIdentity };
|
|||||||
export interface AppDependencies {
|
export interface AppDependencies {
|
||||||
auth: AuthVerifier;
|
auth: AuthVerifier;
|
||||||
repository: ApiRepository;
|
repository: ApiRepository;
|
||||||
|
corsAllowedOrigins?: string[];
|
||||||
coupleService?: CoupleService;
|
coupleService?: CoupleService;
|
||||||
packsRepository?: PacksRepository;
|
packsRepository?: PacksRepository;
|
||||||
answersRepository?: AnswersRepository;
|
answersRepository?: AnswersRepository;
|
||||||
chatRepository?: ChatRepository;
|
chatRepository?: ChatRepository;
|
||||||
daresRepository?: DaresRepository;
|
daresRepository?: DaresRepository;
|
||||||
|
quizRepository?: QuizRepository;
|
||||||
profileSettingsRepository?: ProfileSettingsRepository;
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
accountOperationsService?: AccountOperationsService;
|
accountOperationsService?: AccountOperationsService;
|
||||||
mediaRepository?: MediaRepository;
|
mediaRepository?: MediaRepository;
|
||||||
@@ -58,6 +63,15 @@ function error(code: string, message: string): ApiErrorResponse {
|
|||||||
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
||||||
const app = new 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) => {
|
app.use('*', async (c, next) => {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
@@ -126,6 +140,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
|||||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||||
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||||
|
if (deps.quizRepository) registerQuizRoutes(app, deps.quizRepository);
|
||||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||||
|
|||||||
@@ -2,10 +2,30 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional());
|
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 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({
|
const baseSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||||
|
CORS_ALLOWED_ORIGINS: corsOrigins,
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
|
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),
|
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 {
|
export interface AppConfig {
|
||||||
nodeEnv: 'development' | 'test' | 'production';
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
port: number;
|
port: number;
|
||||||
|
corsAllowedOrigins: string[];
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
|
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
|
||||||
authIssuer: string;
|
authIssuer: string;
|
||||||
@@ -84,6 +105,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
return {
|
return {
|
||||||
nodeEnv: parsed.NODE_ENV,
|
nodeEnv: parsed.NODE_ENV,
|
||||||
port: parsed.PORT,
|
port: parsed.PORT,
|
||||||
|
corsAllowedOrigins: parsed.CORS_ALLOWED_ORIGINS,
|
||||||
databaseUrl: parsed.DATABASE_URL,
|
databaseUrl: parsed.DATABASE_URL,
|
||||||
databasePool: {
|
databasePool: {
|
||||||
max: parsed.DATABASE_POOL_MAX,
|
max: parsed.DATABASE_POOL_MAX,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
|
|||||||
export * from '../domains/answers/schema.js';
|
export * from '../domains/answers/schema.js';
|
||||||
export * from '../domains/chat/schema.js';
|
export * from '../domains/chat/schema.js';
|
||||||
export * from '../domains/app-data/schema.js';
|
export * from '../domains/app-data/schema.js';
|
||||||
|
export * from '../domains/quiz/schema.js';
|
||||||
@@ -32,6 +32,7 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
|||||||
is_premium: row.isPremium,
|
is_premium: row.isPremium,
|
||||||
couple_id: row.coupleId,
|
couple_id: row.coupleId,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
|
usage_reason: row.usageReason,
|
||||||
show_explicit_content: row.showExplicitContent,
|
show_explicit_content: row.showExplicitContent,
|
||||||
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const profiles = pgTable('profiles', {
|
|||||||
isPremium: boolean('is_premium').notNull().default(false),
|
isPremium: boolean('is_premium').notNull().default(false),
|
||||||
coupleId: uuid('couple_id'),
|
coupleId: uuid('couple_id'),
|
||||||
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
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),
|
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
|
||||||
maxIntensity: integer('max_intensity').notNull().default(5),
|
maxIntensity: integer('max_intensity').notNull().default(5),
|
||||||
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ const resources: Record<string, ResourceRule> = {
|
|||||||
media_objects: { read: 'view_media' },
|
media_objects: { read: 'view_media' },
|
||||||
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
|
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
|
||||||
dares: { read: 'manage_questions', write: 'manage_questions' },
|
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 {
|
function identifier(value: string): string {
|
||||||
|
|||||||
@@ -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 }>>;
|
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 }>;
|
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 }>;
|
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>>;
|
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
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 }>;
|
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;
|
private zones: Promise<Set<string>> | null = null;
|
||||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
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> {
|
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 result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||||
const row = result.rows[0];
|
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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,15 +154,25 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
await this.assertEligible(client,userId,ctx,q);
|
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]);
|
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) {
|
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) {
|
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 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)
|
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]);
|
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);
|
if (!ctx.couple_id) {
|
||||||
await this.touchStreak(client,userId,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 };
|
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 };
|
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 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 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}:{}) };
|
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Couple, Profile } from '@sauci/shared';
|
import type { Couple, Profile } from '@sauci/shared';
|
||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
import { calculateMatchType, type Answer } from '../answers/types.js';
|
||||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -77,6 +78,60 @@ async function profileForUpdate(client: PoolClient, userId: string): Promise<{ c
|
|||||||
return profile;
|
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> {
|
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -111,7 +166,8 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
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([
|
const [coupleResult, partnerResult] = await Promise.all([
|
||||||
this.pool.query<CoupleRecord>(
|
this.pool.query<CoupleRecord>(
|
||||||
@@ -129,13 +185,22 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const couple = coupleResult.rows[0];
|
const couple = coupleResult.rows[0];
|
||||||
if (!couple) return { couple: null, partner: null };
|
if (!couple) return { couple: null, partner: null, sealed_count: sealedCount };
|
||||||
return {
|
return {
|
||||||
couple: toCouple(couple),
|
couple: toCouple(couple),
|
||||||
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
|
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> {
|
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
|
||||||
return transaction(this.pool, async (client) => {
|
return transaction(this.pool, async (client) => {
|
||||||
const profile = await profileForUpdate(client, userId);
|
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 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);
|
return toCouple(couple);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Couple, Profile } from '@sauci/shared';
|
|||||||
export interface CoupleStateResponse {
|
export interface CoupleStateResponse {
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
partner: Profile | 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 {
|
export interface CoupleMutationResponse {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../d
|
|||||||
export type DareStatus =
|
export type DareStatus =
|
||||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||||
|
|
||||||
|
/** Sender-chosen proof of completion; 'none' means a plain "I did it" suffices. */
|
||||||
|
export type DareProofType = 'none' | 'photo' | 'audio';
|
||||||
|
|
||||||
|
export const DARE_PROOF_TYPES: readonly DareProofType[] = ['none', 'photo', 'audio'];
|
||||||
|
|
||||||
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
|
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
|
||||||
export const FREE_WEEKLY_SEND_LIMIT = 3;
|
export const FREE_WEEKLY_SEND_LIMIT = 3;
|
||||||
|
|
||||||
@@ -45,6 +50,8 @@ export interface SentDare {
|
|||||||
direction: 'incoming' | 'outgoing';
|
direction: 'incoming' | 'outgoing';
|
||||||
status: DareStatus;
|
status: DareStatus;
|
||||||
sender_notes: string | null;
|
sender_notes: string | null;
|
||||||
|
proof_type: DareProofType;
|
||||||
|
proof_media_id: string | null;
|
||||||
sent_at: string;
|
sent_at: string;
|
||||||
accepted_at: string | null;
|
accepted_at: string | null;
|
||||||
submitted_at: string | null;
|
submitted_at: string | null;
|
||||||
@@ -79,6 +86,7 @@ export interface SendDareInput {
|
|||||||
custom_dare_intensity?: number | null;
|
custom_dare_intensity?: number | null;
|
||||||
duration_hours?: number | null;
|
duration_hours?: number | null;
|
||||||
sender_notes?: string | null;
|
sender_notes?: string | null;
|
||||||
|
proof_type?: DareProofType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DaresError extends Error {
|
export class DaresError extends Error {
|
||||||
@@ -111,6 +119,8 @@ interface SentDareRecord extends QueryResultRow {
|
|||||||
recipient_id: string;
|
recipient_id: string;
|
||||||
status: DareStatus;
|
status: DareStatus;
|
||||||
sender_notes: string | null;
|
sender_notes: string | null;
|
||||||
|
proof_type: DareProofType;
|
||||||
|
proof_media_id: string | null;
|
||||||
sent_at: Date;
|
sent_at: Date;
|
||||||
accepted_at: Date | null;
|
accepted_at: Date | null;
|
||||||
submitted_at: Date | null;
|
submitted_at: Date | null;
|
||||||
@@ -124,7 +134,7 @@ export interface DaresRepository {
|
|||||||
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
|
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
|
||||||
send(userId: string, input: SendDareInput): Promise<SentDare>;
|
send(userId: string, input: SendDareInput): Promise<SentDare>;
|
||||||
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
|
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
|
||||||
submit(userId: string, dareId: string): Promise<SentDare>;
|
submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare>;
|
||||||
complete(userId: string, dareId: string): Promise<SentDare>;
|
complete(userId: string, dareId: string): Promise<SentDare>;
|
||||||
cancel(userId: string, dareId: string): Promise<SentDare>;
|
cancel(userId: string, dareId: string): Promise<SentDare>;
|
||||||
stats(userId: string): Promise<DareStats>;
|
stats(userId: string): Promise<DareStats>;
|
||||||
@@ -132,7 +142,7 @@ export interface DaresRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
|
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
|
||||||
custom_dare_text, sender_id, recipient_id, status, sender_notes,
|
custom_dare_text, sender_id, recipient_id, status, sender_notes, proof_type, proof_media_id,
|
||||||
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
|
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
|
||||||
|
|
||||||
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
|
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
|
||||||
@@ -172,6 +182,8 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
|||||||
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
|
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
|
||||||
status: row.status,
|
status: row.status,
|
||||||
sender_notes: row.sender_notes,
|
sender_notes: row.sender_notes,
|
||||||
|
proof_type: row.proof_type,
|
||||||
|
proof_media_id: row.proof_media_id,
|
||||||
sent_at: row.sent_at.toISOString(),
|
sent_at: row.sent_at.toISOString(),
|
||||||
accepted_at: iso(row.accepted_at),
|
accepted_at: iso(row.accepted_at),
|
||||||
submitted_at: iso(row.submitted_at),
|
submitted_at: iso(row.submitted_at),
|
||||||
@@ -323,6 +335,11 @@ export class PostgresDaresRepository implements DaresRepository {
|
|||||||
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
|
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const proofType = input.proof_type ?? 'none';
|
||||||
|
if (!DARE_PROOF_TYPES.includes(proofType)) {
|
||||||
|
throw new DaresError('invalid_proof_type', 'Proof must be none, photo, or audio', 400);
|
||||||
|
}
|
||||||
|
|
||||||
let text: string;
|
let text: string;
|
||||||
let intensity: number;
|
let intensity: number;
|
||||||
let dareId: string | null = null;
|
let dareId: string | null = null;
|
||||||
@@ -376,15 +393,15 @@ export class PostgresDaresRepository implements DaresRepository {
|
|||||||
`insert into sent_dares (
|
`insert into sent_dares (
|
||||||
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
|
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
|
||||||
dare_text_snapshot, dare_intensity_snapshot,
|
dare_text_snapshot, dare_intensity_snapshot,
|
||||||
sender_id, recipient_id, sender_notes, expires_at
|
sender_id, recipient_id, sender_notes, proof_type, expires_at
|
||||||
) values (
|
) values (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
|
case when $11::integer is null then null else now() + ($11::integer * interval '1 hour') end
|
||||||
)
|
)
|
||||||
returning ${BARE_COLUMNS}`,
|
returning ${BARE_COLUMNS}`,
|
||||||
[
|
[
|
||||||
context.couple_id, dareId, customText, customIntensity, text, intensity,
|
context.couple_id, dareId, customText, customIntensity, text, intensity,
|
||||||
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
|
userId, context.partner_id, input.sender_notes?.trim() || null, proofType, duration,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return toSentDare(result.rows[0]!, userId);
|
return toSentDare(result.rows[0]!, userId);
|
||||||
@@ -438,8 +455,59 @@ export class PostgresDaresRepository implements DaresRepository {
|
|||||||
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
|
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit(userId: string, dareId: string): Promise<SentDare> {
|
/**
|
||||||
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
|
* Submission enforces the sender's proof requirement: a proof-required dare
|
||||||
|
* cannot be marked done without media of the matching type. Proof is also
|
||||||
|
* accepted voluntarily on 'none' dares. Media ownership, kind, and couple are
|
||||||
|
* re-checked here so a submit cannot reference someone else's upload.
|
||||||
|
*/
|
||||||
|
async submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const existing = await client.query<{
|
||||||
|
sender_id: string; recipient_id: string; status: DareStatus;
|
||||||
|
proof_type: DareProofType; couple_id: string;
|
||||||
|
}>(
|
||||||
|
'select sender_id, recipient_id, status, proof_type, couple_id from sent_dares where id = $1 for update',
|
||||||
|
[dareId],
|
||||||
|
);
|
||||||
|
const dare = existing.rows[0];
|
||||||
|
if (!dare || (dare.sender_id !== userId && dare.recipient_id !== userId)) {
|
||||||
|
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||||
|
}
|
||||||
|
if (dare.recipient_id !== userId) {
|
||||||
|
throw new DaresError('not_permitted', 'Only the recipient can do that', 403);
|
||||||
|
}
|
||||||
|
if (dare.status !== 'active') {
|
||||||
|
throw new DaresError('invalid_transition', `A ${dare.status} dare cannot change to submitted`, 409);
|
||||||
|
}
|
||||||
|
if (dare.proof_type !== 'none' && !proofMediaId) {
|
||||||
|
throw new DaresError('proof_required', `This dare requires ${dare.proof_type} proof`, 400);
|
||||||
|
}
|
||||||
|
if (proofMediaId) {
|
||||||
|
const media = await client.query<{ kind: string; mime_type: string; owner_id: string; couple_id: string | null }>(
|
||||||
|
'select kind, mime_type, owner_id, couple_id from media_objects where id = $1 and deleted_at is null',
|
||||||
|
[proofMediaId],
|
||||||
|
);
|
||||||
|
const proof = media.rows[0];
|
||||||
|
if (!proof || proof.owner_id !== userId || proof.kind !== 'dare_proof' || proof.couple_id !== dare.couple_id) {
|
||||||
|
throw new DaresError('proof_not_found', 'Proof media not found', 404);
|
||||||
|
}
|
||||||
|
const category = dare.proof_type === 'none'
|
||||||
|
? (proof.mime_type.startsWith('image/') || proof.mime_type.startsWith('audio/'))
|
||||||
|
: proof.mime_type.startsWith(dare.proof_type === 'photo' ? 'image/' : 'audio/');
|
||||||
|
if (!category) {
|
||||||
|
throw new DaresError('proof_type_mismatch', `This dare requires ${dare.proof_type} proof`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await client.query<SentDareRecord>(
|
||||||
|
`update sent_dares
|
||||||
|
set status = 'submitted', submitted_at = now(), proof_media_id = $2::uuid
|
||||||
|
where id = $1
|
||||||
|
returning ${BARE_COLUMNS}`,
|
||||||
|
[dareId, proofMediaId ?? null],
|
||||||
|
);
|
||||||
|
return toSentDare(result.rows[0]!, userId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(userId: string, dareId: string): Promise<SentDare> {
|
async complete(userId: string, dareId: string): Promise<SentDare> {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const sendBody = z
|
|||||||
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
|
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
|
||||||
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
|
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
|
||||||
sender_notes: z.string().trim().max(500).nullish(),
|
sender_notes: z.string().trim().max(500).nullish(),
|
||||||
|
proof_type: z.enum(['none', 'photo', 'audio']).nullish(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
|
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
|
||||||
@@ -23,6 +24,8 @@ const sendBody = z
|
|||||||
|
|
||||||
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
|
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
|
||||||
|
|
||||||
|
const submitBody = z.object({ proof_media_id: id.nullish() }).strict();
|
||||||
|
|
||||||
function error(code: string, message: string): ApiErrorResponse {
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
return { error: { code, message } };
|
return { error: { code, message } };
|
||||||
}
|
}
|
||||||
@@ -93,8 +96,20 @@ export function registerDareRoutes(app: DaresApp, repository: DaresRepository):
|
|||||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/v1/dares/:dareId/submit', async (c) => {
|
||||||
|
const dareId = id.safeParse(c.req.param('dareId'));
|
||||||
|
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||||
|
// Body is optional: dares without a proof requirement submit with no payload.
|
||||||
|
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!body.success) return c.json(error('invalid_proof', 'proof_media_id must be a valid media ID'), 400);
|
||||||
|
const response = await result(() =>
|
||||||
|
repository.submit(c.get('identity').id, dareId.data, body.data.proof_media_id ?? null));
|
||||||
|
return response.ok
|
||||||
|
? c.json({ dare: response.value })
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
for (const [path, method] of [
|
for (const [path, method] of [
|
||||||
['submit', 'submit'],
|
|
||||||
['complete', 'complete'],
|
['complete', 'complete'],
|
||||||
['cancel', 'cancel'],
|
['cancel', 'cancel'],
|
||||||
] as const) {
|
] as const) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class PostgresMediaRepository implements MediaRepository {
|
|||||||
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
|
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
|
||||||
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
|
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
|
||||||
const coupleId = profile.rows[0].couple_id;
|
const coupleId = profile.rows[0].couple_id;
|
||||||
if ((kind === 'response' || kind === 'chat') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
if ((kind === 'response' || kind === 'chat' || kind === 'dare_proof') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
||||||
if (kind === 'response') {
|
if (kind === 'response') {
|
||||||
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
|
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
|
||||||
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
|
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
|
||||||
@@ -84,7 +84,7 @@ export class PostgresMediaRepository implements MediaRepository {
|
|||||||
from media_objects mo join profiles viewer on viewer.id=$1
|
from media_objects mo join profiles viewer on viewer.id=$1
|
||||||
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
|
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
|
||||||
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
|
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
|
||||||
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat','dare_proof') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
||||||
[userId, mediaId],
|
[userId, mediaId],
|
||||||
);
|
);
|
||||||
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
|
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const allowed: Record<MediaKind, ReadonlySet<string>> = {
|
|||||||
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||||
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
|
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
|
||||||
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||||
|
dare_proof: new Set(['image/jpeg', 'image/png', 'image/webp', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||||
};
|
};
|
||||||
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 };
|
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024, dare_proof: 25*1024*1024 };
|
||||||
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
|
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
|
||||||
|
|
||||||
export class FilesystemMediaStorage {
|
export class FilesystemMediaStorage {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const;
|
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback', 'dare_proof'] as const;
|
||||||
export type MediaKind = typeof mediaKinds[number];
|
export type MediaKind = typeof mediaKinds[number];
|
||||||
|
|
||||||
export interface MediaObject {
|
export interface MediaObject {
|
||||||
|
|||||||
@@ -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')
|
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]);
|
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(
|
const unpaired=await client.query(
|
||||||
`with eligible as (
|
`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
|
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 (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)
|
and coalesce(np.unpaired_reminders_enabled,true)
|
||||||
@@ -143,10 +148,11 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
), queued as (
|
), queued as (
|
||||||
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||||
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
|
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
|
jsonb_build_object('title','Sauci','body',case
|
||||||
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
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,
|
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
|
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]);
|
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const questionPacks = pgTable('question_packs', {
|
|||||||
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||||
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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)]);
|
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
||||||
|
|
||||||
export const questions = pgTable('questions', {
|
export const questions = pgTable('questions', {
|
||||||
@@ -61,7 +63,9 @@ export const responses = pgTable('responses', {
|
|||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
questionId: uuid('question_id').notNull().references(() => questions.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(),
|
answer: text('answer').notNull(),
|
||||||
responseData: jsonb('response_data'),
|
responseData: jsonb('response_data'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
import { computeQuizScore, type QuizAnswerPair } from './service.js';
|
||||||
|
import {
|
||||||
|
QuizError,
|
||||||
|
type QuizAnswerInput,
|
||||||
|
type QuizQuestionSummary,
|
||||||
|
type QuizResultPayload,
|
||||||
|
type QuizSessionPayload,
|
||||||
|
type QuizSessionStatus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface QuizQuestionRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizSessionRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
status: QuizSessionStatus;
|
||||||
|
question_ids: string[];
|
||||||
|
score_percent: number | null;
|
||||||
|
completed_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizAnswerRow extends QueryResultRow {
|
||||||
|
user_id: string;
|
||||||
|
question_id: string;
|
||||||
|
self_index: number;
|
||||||
|
guess_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizRepository {
|
||||||
|
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
|
||||||
|
currentSession(userId: string): Promise<QuizSessionPayload | null>;
|
||||||
|
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
|
||||||
|
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUESTIONS_PER_SESSION = 10;
|
||||||
|
|
||||||
|
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const result = await work(client);
|
||||||
|
await client.query('commit');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
|
||||||
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresQuizRepository implements QuizRepository {
|
||||||
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
|
constructor(connection: DatabaseConnection) {
|
||||||
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
|
||||||
|
const result = await client.query<{ couple_id: string | null }>(
|
||||||
|
'select couple_id from profiles where id = $1',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.couple_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
|
||||||
|
const result = await client.query<{ id: string }>(
|
||||||
|
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
|
||||||
|
[coupleId, userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
|
||||||
|
const result = await client.query<QuizQuestionRow>(
|
||||||
|
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
|
||||||
|
[questionIds],
|
||||||
|
);
|
||||||
|
return orderByIds(result.rows, questionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildPayload(
|
||||||
|
client: Pool | PoolClient,
|
||||||
|
session: QuizSessionRow,
|
||||||
|
userId: string,
|
||||||
|
): Promise<QuizSessionPayload> {
|
||||||
|
const questions = await this.questionsFor(client, session.question_ids);
|
||||||
|
const [myAnswers, counts] = await Promise.all([
|
||||||
|
client.query<QuizAnswerRow>(
|
||||||
|
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
|
||||||
|
[session.id, userId],
|
||||||
|
),
|
||||||
|
client.query<{ user_id: string; count: string }>(
|
||||||
|
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||||
|
[session.id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const coupleId = session.couple_id;
|
||||||
|
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||||
|
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||||
|
const questionCount = session.question_ids.length;
|
||||||
|
|
||||||
|
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
|
||||||
|
const orderedAnswers: QuizAnswerInput[] = session.question_ids
|
||||||
|
.map((questionId) => answersByQuestion.get(questionId))
|
||||||
|
.filter((row): row is QuizAnswerRow => row !== undefined)
|
||||||
|
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
|
||||||
|
|
||||||
|
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
prompt_self: row.prompt_self,
|
||||||
|
prompt_guess: row.prompt_guess,
|
||||||
|
options: row.options,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
status: session.status,
|
||||||
|
created_at: session.created_at.toISOString(),
|
||||||
|
completed_at: session.completed_at?.toISOString() ?? null,
|
||||||
|
score_percent: session.score_percent,
|
||||||
|
questions: questionSummaries,
|
||||||
|
my_answers: orderedAnswers,
|
||||||
|
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
|
||||||
|
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const coupleId = await this.coupleOf(client, userId);
|
||||||
|
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
|
||||||
|
|
||||||
|
const members = await client.query<{ count: string }>(
|
||||||
|
'select count(*)::text as count from profiles where couple_id = $1',
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
if (Number(members.rows[0]?.count ?? 0) < 2) {
|
||||||
|
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await client.query<QuizSessionRow>(
|
||||||
|
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer questions this couple has never had in any prior session (completed or
|
||||||
|
// active), then the least-recently-used ones, with a random tiebreak within each
|
||||||
|
// group so repeat quizzes stay varied instead of always reusing the same pool.
|
||||||
|
const questions = await client.query<{ id: string }>(
|
||||||
|
`select q.id
|
||||||
|
from quiz_questions q
|
||||||
|
left join (
|
||||||
|
select question_id, max(created_at) as last_used
|
||||||
|
from quiz_sessions, unnest(question_ids) as question_id
|
||||||
|
where couple_id = $1
|
||||||
|
group by question_id
|
||||||
|
) usage on usage.question_id = q.id
|
||||||
|
where q.is_active = true
|
||||||
|
order by (usage.last_used is not null), usage.last_used asc, random()
|
||||||
|
limit $2`,
|
||||||
|
[coupleId, QUESTIONS_PER_SESSION],
|
||||||
|
);
|
||||||
|
const questionIds = questions.rows.map((row) => row.id);
|
||||||
|
|
||||||
|
// The partial unique index (one active session per couple) is the source of
|
||||||
|
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
|
||||||
|
// lets the loser fall through to reading the winner's row instead of erroring.
|
||||||
|
const inserted = await client.query<QuizSessionRow>(
|
||||||
|
`insert into quiz_sessions (couple_id, question_ids)
|
||||||
|
values ($1, $2::uuid[])
|
||||||
|
on conflict (couple_id) where status = 'active' do nothing
|
||||||
|
returning *`,
|
||||||
|
[coupleId, questionIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
let sessionRow = inserted.rows[0];
|
||||||
|
let created = true;
|
||||||
|
if (!sessionRow) {
|
||||||
|
created = false;
|
||||||
|
const raced = await client.query<QuizSessionRow>(
|
||||||
|
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
sessionRow = raced.rows[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session: await this.buildPayload(client, sessionRow, userId), created };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
|
||||||
|
const coupleId = await this.coupleOf(this.pool, userId);
|
||||||
|
if (!coupleId) return null;
|
||||||
|
const result = await this.pool.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return this.buildPayload(this.pool, row, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const sessionResult = await client.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where id = $1 for update',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
const coupleId = await this.coupleOf(client, userId);
|
||||||
|
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||||
|
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
}
|
||||||
|
if (session.status !== 'active') {
|
||||||
|
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionIds = new Set(session.question_ids);
|
||||||
|
const answeredIds = new Set(answers.map((answer) => answer.question_id));
|
||||||
|
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
for (const id of answeredIds) {
|
||||||
|
if (!questionIds.has(id)) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = await this.questionsFor(client, session.question_ids);
|
||||||
|
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
|
||||||
|
for (const answer of answers) {
|
||||||
|
const options = optionsByQuestion.get(answer.question_id);
|
||||||
|
if (!options) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
answer.self_index < 0 || answer.self_index >= options.length ||
|
||||||
|
answer.guess_index < 0 || answer.guess_index >= options.length
|
||||||
|
) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const answer of answers) {
|
||||||
|
await client.query(
|
||||||
|
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
|
||||||
|
values ($1, $2, $3, $4, $5)
|
||||||
|
on conflict (session_id, user_id, question_id)
|
||||||
|
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
|
||||||
|
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||||
|
let updatedSession = session;
|
||||||
|
if (partnerId) {
|
||||||
|
const counts = await client.query<{ user_id: string; count: string }>(
|
||||||
|
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||||
|
const questionCount = session.question_ids.length;
|
||||||
|
const bothComplete =
|
||||||
|
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
|
||||||
|
|
||||||
|
if (bothComplete) {
|
||||||
|
const allAnswers = await client.query<QuizAnswerRow>(
|
||||||
|
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||||
|
for (const row of allAnswers.rows) {
|
||||||
|
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||||
|
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||||
|
}
|
||||||
|
const mine = byUser.get(userId)!;
|
||||||
|
const theirs = byUser.get(partnerId)!;
|
||||||
|
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
|
||||||
|
questionId,
|
||||||
|
aSelfIndex: mine.get(questionId)!.self_index,
|
||||||
|
aGuessIndex: mine.get(questionId)!.guess_index,
|
||||||
|
bSelfIndex: theirs.get(questionId)!.self_index,
|
||||||
|
bGuessIndex: theirs.get(questionId)!.guess_index,
|
||||||
|
}));
|
||||||
|
const { scorePercent } = computeQuizScore(pairs);
|
||||||
|
|
||||||
|
const completed = await client.query<QuizSessionRow>(
|
||||||
|
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
|
||||||
|
where id = $1
|
||||||
|
returning *`,
|
||||||
|
[sessionId, scorePercent],
|
||||||
|
);
|
||||||
|
updatedSession = completed.rows[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildPayload(client, updatedSession, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
|
||||||
|
const sessionResult = await this.pool.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
const coupleId = await this.coupleOf(this.pool, userId);
|
||||||
|
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||||
|
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
}
|
||||||
|
if (session.status !== 'completed') {
|
||||||
|
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
|
||||||
|
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
|
||||||
|
const [questions, allAnswers] = await Promise.all([
|
||||||
|
this.questionsFor(this.pool, session.question_ids),
|
||||||
|
this.pool.query<QuizAnswerRow>(
|
||||||
|
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||||
|
[sessionId],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||||
|
for (const row of allAnswers.rows) {
|
||||||
|
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||||
|
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||||
|
}
|
||||||
|
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
|
||||||
|
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
|
||||||
|
|
||||||
|
const questionsPayload = questions.map((question) => {
|
||||||
|
const my = mine.get(question.id);
|
||||||
|
const partner = theirs.get(question.id);
|
||||||
|
const mySelf = my?.self_index ?? 0;
|
||||||
|
const myGuess = my?.guess_index ?? 0;
|
||||||
|
const partnerSelf = partner?.self_index ?? 0;
|
||||||
|
const partnerGuess = partner?.guess_index ?? 0;
|
||||||
|
return {
|
||||||
|
question_id: question.id,
|
||||||
|
prompt_self: question.prompt_self,
|
||||||
|
prompt_guess: question.prompt_guess,
|
||||||
|
options: question.options,
|
||||||
|
my_self_index: mySelf,
|
||||||
|
my_guess_index: myGuess,
|
||||||
|
partner_self_index: partnerSelf,
|
||||||
|
partner_guess_index: partnerGuess,
|
||||||
|
i_guessed_right: myGuess === partnerSelf,
|
||||||
|
partner_guessed_right: partnerGuess === mySelf,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
score_percent: session.score_percent ?? 0,
|
||||||
|
completed_at: session.completed_at?.toISOString() ?? null,
|
||||||
|
questions: questionsPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ApiErrorResponse } from '@sauci/shared';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
|
import { QuizError, type QuizAnswerInput } from './types.js';
|
||||||
|
import type { QuizRepository } from './repository.js';
|
||||||
|
|
||||||
|
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
|
|
||||||
|
const id = z.string().uuid();
|
||||||
|
|
||||||
|
const answerEntry = z
|
||||||
|
.object({
|
||||||
|
question_id: id,
|
||||||
|
self_index: z.number().int().min(0),
|
||||||
|
guess_index: z.number().int().min(0),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
|
||||||
|
|
||||||
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
|
return { error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function result<T>(operation: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return { ok: true as const, value: await operation() };
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof QuizError) return { ok: false as const, cause };
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
|
||||||
|
app.post('/v1/quiz/sessions', async (c) => {
|
||||||
|
const response = await result(() => repository.startSession(c.get('identity').id));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/quiz/sessions/current', async (c) => {
|
||||||
|
const response = await result(() => repository.currentSession(c.get('identity').id));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
|
||||||
|
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||||
|
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||||
|
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
|
||||||
|
const answers: QuizAnswerInput[] = body.data.answers;
|
||||||
|
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
|
||||||
|
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||||
|
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||||
|
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json(response.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { couples } from '../couples/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
|
||||||
|
* same question so a session can ask each partner about themselves and about the
|
||||||
|
* other without a second row.
|
||||||
|
*/
|
||||||
|
export const quizQuestions = pgTable('quiz_questions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
promptSelf: text('prompt_self').notNull().unique(),
|
||||||
|
promptGuess: text('prompt_guess').notNull(),
|
||||||
|
options: text('options').array().notNull(),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const quizSessions = pgTable('quiz_sessions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||||
|
status: text('status').notNull().default('active'),
|
||||||
|
questionIds: uuid('question_ids').array().notNull(),
|
||||||
|
scorePercent: integer('score_percent'),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
|
||||||
|
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
|
||||||
|
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const quizAnswers = pgTable('quiz_answers', {
|
||||||
|
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
|
||||||
|
userId: uuid('user_id').notNull(),
|
||||||
|
questionId: uuid('question_id').notNull(),
|
||||||
|
selfIndex: integer('self_index').notNull(),
|
||||||
|
guessIndex: integer('guess_index').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
|
||||||
|
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
|
||||||
|
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
|
||||||
|
export type QuizSessionRow = typeof quizSessions.$inferSelect;
|
||||||
|
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* One partner's guess of the other's self-answer, for a single question. Scoring
|
||||||
|
* always runs both directions: A guessing B and B guessing A each count separately,
|
||||||
|
* so a couple who only ever gets half the guesses right lands at 50%, not 100%.
|
||||||
|
*/
|
||||||
|
export interface QuizAnswerPair {
|
||||||
|
questionId: string;
|
||||||
|
aSelfIndex: number;
|
||||||
|
aGuessIndex: number;
|
||||||
|
bSelfIndex: number;
|
||||||
|
bGuessIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizScoreQuestionResult extends QuizAnswerPair {
|
||||||
|
aGuessedRight: boolean;
|
||||||
|
bGuessedRight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizScoreResult {
|
||||||
|
scorePercent: number;
|
||||||
|
perQuestion: QuizScoreQuestionResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure scoring function for a completed quiz session. Each question contributes
|
||||||
|
* up to two hits (partner A's guess about B, and B's guess about A), so the
|
||||||
|
* denominator is twice the question count.
|
||||||
|
*/
|
||||||
|
export function computeQuizScore(pairs: QuizAnswerPair[]): QuizScoreResult {
|
||||||
|
const perQuestion = pairs.map((pair) => ({
|
||||||
|
...pair,
|
||||||
|
aGuessedRight: pair.aGuessIndex === pair.bSelfIndex,
|
||||||
|
bGuessedRight: pair.bGuessIndex === pair.aSelfIndex,
|
||||||
|
}));
|
||||||
|
const hits = perQuestion.reduce(
|
||||||
|
(sum, question) => sum + (question.aGuessedRight ? 1 : 0) + (question.bGuessedRight ? 1 : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const scorePercent = pairs.length === 0 ? 0 : Math.round((100 * hits) / (2 * pairs.length));
|
||||||
|
return { scorePercent, perQuestion };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type QuizSessionStatus = 'active' | 'completed';
|
||||||
|
|
||||||
|
export type QuizErrorCode =
|
||||||
|
| 'no_couple'
|
||||||
|
| 'partner_required'
|
||||||
|
| 'session_not_found'
|
||||||
|
| 'session_completed'
|
||||||
|
| 'invalid_answers'
|
||||||
|
| 'session_not_completed';
|
||||||
|
|
||||||
|
export class QuizError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: QuizErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly status: 400 | 404 | 409,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'QuizError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizAnswerInput {
|
||||||
|
question_id: string;
|
||||||
|
self_index: number;
|
||||||
|
guess_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizQuestionSummary {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizSessionPayload {
|
||||||
|
id: string;
|
||||||
|
status: QuizSessionStatus;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
score_percent: number | null;
|
||||||
|
questions: QuizQuestionSummary[];
|
||||||
|
my_answers: QuizAnswerInput[];
|
||||||
|
partner_completed: boolean;
|
||||||
|
i_completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResultQuestion {
|
||||||
|
question_id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
my_self_index: number;
|
||||||
|
my_guess_index: number;
|
||||||
|
partner_self_index: number;
|
||||||
|
partner_guess_index: number;
|
||||||
|
i_guessed_right: boolean;
|
||||||
|
partner_guessed_right: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResultPayload {
|
||||||
|
score_percent: number;
|
||||||
|
completed_at: string | null;
|
||||||
|
questions: QuizResultQuestion[];
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||||
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||||
|
import { PostgresQuizRepository } from './domains/quiz/repository.js';
|
||||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||||
import { CoupleService } from './domains/couples/service.js';
|
import { CoupleService } from './domains/couples/service.js';
|
||||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
|
|||||||
const answersRepository = new PostgresAnswersRepository(pool);
|
const answersRepository = new PostgresAnswersRepository(pool);
|
||||||
const chatRepository = new PostgresChatRepository(pool);
|
const chatRepository = new PostgresChatRepository(pool);
|
||||||
const daresRepository = new PostgresDaresRepository(pool);
|
const daresRepository = new PostgresDaresRepository(pool);
|
||||||
|
const quizRepository = new PostgresQuizRepository(pool);
|
||||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
||||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
||||||
const accountOperationsService = new AccountOperationsService(
|
const accountOperationsService = new AccountOperationsService(
|
||||||
@@ -63,11 +65,13 @@ const adminRepository = new PostgresAdminRepository(pool, {
|
|||||||
const app = createApp({
|
const app = createApp({
|
||||||
auth: authVerifier,
|
auth: authVerifier,
|
||||||
repository,
|
repository,
|
||||||
|
corsAllowedOrigins: config.corsAllowedOrigins,
|
||||||
coupleService: new CoupleService(coupleRepository),
|
coupleService: new CoupleService(coupleRepository),
|
||||||
packsRepository,
|
packsRepository,
|
||||||
answersRepository,
|
answersRepository,
|
||||||
chatRepository,
|
chatRepository,
|
||||||
daresRepository,
|
daresRepository,
|
||||||
|
quizRepository,
|
||||||
profileSettingsRepository,
|
profileSettingsRepository,
|
||||||
accountOperationsService,
|
accountOperationsService,
|
||||||
mediaRepository,
|
mediaRepository,
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi
|
|||||||
const responseQuestion = '88888888-8888-4888-8888-888888888888';
|
const responseQuestion = '88888888-8888-4888-8888-888888888888';
|
||||||
const responseId = '99999999-9999-4999-8999-999999999999';
|
const responseId = '99999999-9999-4999-8999-999999999999';
|
||||||
const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||||
|
const quizCouple = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
let pool: Pool; let repository: PostgresAdminRepository;
|
let pool: Pool; let repository: PostgresAdminRepository;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await adminPool.query(`create schema "${schema}"`);
|
await adminPool.query(`create schema "${schema}"`);
|
||||||
const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${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());
|
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');
|
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);
|
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' });
|
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 () => {
|
it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => {
|
||||||
const tables = await pool.query<{ table_name: string }>(
|
const tables = await pool.query<{ table_name: string }>(
|
||||||
`select table_name from information_schema.tables where table_schema=current_schema()
|
`select table_name from information_schema.tables where table_schema=current_schema()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
|||||||
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
|
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');
|
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;
|
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';
|
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']){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();});
|
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('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('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);});
|
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");
|
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());}});
|
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);});
|
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});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -28,7 +28,7 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
|
|||||||
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
||||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||||
repository = new PostgresRepository(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');
|
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
|
||||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||||
if (statement.trim()) await pool.query(statement);
|
if (statement.trim()) await pool.query(statement);
|
||||||
|
|||||||
@@ -81,6 +81,35 @@ describe('Sauci API', () => {
|
|||||||
expect(repository.ready).toHaveBeenCalledOnce();
|
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 () => {
|
it('fails closed when the bearer token is missing or invalid', async () => {
|
||||||
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
|
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
|
||||||
expect((await app.request('/v1/me')).status).toBe(401);
|
expect((await app.request('/v1/me')).status).toBe(401);
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
|
|||||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||||
repository = new PostgresCoupleRepository(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');
|
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
|
||||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||||
if (statement.trim()) await pool.query(statement);
|
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 expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' });
|
||||||
|
|
||||||
await repository.cancel(alice);
|
await repository.cancel(alice);
|
||||||
await expect(repository.getState(alice)).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 });
|
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);
|
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' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -14,7 +14,7 @@ const identity: AuthIdentity = {
|
|||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const repository: CoupleRepository = {
|
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) => ({
|
create: vi.fn(async (_userId, coupleId, inviteCode) => ({
|
||||||
id: coupleId,
|
id: coupleId,
|
||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
@@ -43,7 +43,7 @@ describe('couple routes', () => {
|
|||||||
const response = await app.request('/v1/couple');
|
const response = await app.request('/v1/couple');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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);
|
expect(repository.getState).toHaveBeenCalledWith(identity.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const couple: Couple = {
|
|||||||
|
|
||||||
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
|
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
|
||||||
return {
|
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),
|
create: vi.fn(async () => couple),
|
||||||
join: vi.fn(async () => couple),
|
join: vi.fn(async () => couple),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
|
|||||||
@@ -196,6 +196,58 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
|||||||
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function insertProof(owner: string, coupleId: string | null, mime: string, kind = 'dare_proof') {
|
||||||
|
const id = randomUUID();
|
||||||
|
await pool.query(
|
||||||
|
'insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,$4,$5,$6,100)',
|
||||||
|
[id, owner, coupleId, kind, `${kind}/test/${id}.bin`, mime],
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stores the proof requirement and defaults to none', async () => {
|
||||||
|
const plain = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
expect(plain).toMatchObject({ proof_type: 'none', proof_media_id: null });
|
||||||
|
const proofed = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||||
|
expect(proofed.proof_type).toBe('photo');
|
||||||
|
await expect(repo.send(sender, { dare_id: freeDare, proof_type: 'video' as never }))
|
||||||
|
.rejects.toMatchObject({ code: 'invalid_proof_type' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires matching proof media before a proofed dare can be submitted', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
|
||||||
|
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'proof_required' });
|
||||||
|
const audio = await insertProof(recipient, couple, 'audio/mp4');
|
||||||
|
await expect(repo.submit(recipient, dare.id, audio)).rejects.toMatchObject({ code: 'proof_type_mismatch' });
|
||||||
|
|
||||||
|
const photo = await insertProof(recipient, couple, 'image/jpeg');
|
||||||
|
const submitted = await repo.submit(recipient, dare.id, photo);
|
||||||
|
expect(submitted).toMatchObject({ status: 'submitted', proof_media_id: photo });
|
||||||
|
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses proof media the recipient does not own or that is not dare proof', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
|
||||||
|
const foreign = await insertProof(outsider, otherCouple, 'image/jpeg');
|
||||||
|
await expect(repo.submit(recipient, dare.id, foreign)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||||
|
const wrongKind = await insertProof(recipient, couple, 'image/jpeg', 'chat');
|
||||||
|
await expect(repo.submit(recipient, dare.id, wrongKind)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||||
|
const senderOwned = await insertProof(sender, couple, 'image/jpeg');
|
||||||
|
await expect(repo.submit(recipient, dare.id, senderOwned)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts voluntary proof on dares that did not require any', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
const photo = await insertProof(recipient, couple, 'image/jpeg');
|
||||||
|
const submitted = await repo.submit(recipient, dare.id, photo);
|
||||||
|
expect(submitted).toMatchObject({ status: 'submitted', proof_type: 'none', proof_media_id: photo });
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects durations outside the offered presets', async () => {
|
it('rejects durations outside the offered presets', async () => {
|
||||||
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
|||||||
const dare: SentDare = {
|
const dare: SentDare = {
|
||||||
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||||
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||||
direction: 'outgoing', status: 'pending', sender_notes: null,
|
direction: 'outgoing', status: 'pending', sender_notes: null, proof_type: 'none', proof_media_id: null,
|
||||||
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const proofMediaId = '77777777-7777-4777-8777-777777777777';
|
||||||
|
|
||||||
function setup(overrides: Partial<DaresRepository> = {}) {
|
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||||
const repository: DaresRepository = {
|
const repository: DaresRepository = {
|
||||||
getCatalog: vi.fn(async () => ({
|
getCatalog: vi.fn(async () => ({
|
||||||
@@ -91,7 +93,7 @@ describe('dare routes', () => {
|
|||||||
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||||
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||||
await post(app, `/v1/dares/${dareId}/submit`);
|
await post(app, `/v1/dares/${dareId}/submit`);
|
||||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, null);
|
||||||
await post(app, `/v1/dares/${dareId}/complete`);
|
await post(app, `/v1/dares/${dareId}/complete`);
|
||||||
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
await post(app, `/v1/dares/${dareId}/cancel`);
|
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||||
@@ -109,6 +111,22 @@ describe('dare routes', () => {
|
|||||||
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a proof requirement on send and rejects unknown ones', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'photo' });
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ proof_type: 'photo' }));
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'video' })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes proof media through on submit and validates its shape', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: proofMediaId });
|
||||||
|
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, proofMediaId);
|
||||||
|
expect((await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: 'not-a-uuid' })).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/dares/${dareId}/submit`, { extra_field: true })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes the duration presets the send sheet offers', async () => {
|
it('exposes the duration presets the send sheet offers', async () => {
|
||||||
const { app } = setup();
|
const { app } = setup();
|
||||||
const response = await app.request('/v1/dares/durations');
|
const response = await app.request('/v1/dares/durations');
|
||||||
|
|||||||
@@ -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;
|
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
||||||
beforeAll(async()=>{
|
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());
|
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);
|
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']);
|
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);
|
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()=>{
|
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]);
|
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);
|
const result=await repo.produce(new Date(Date.now()+10*60*1000),100);
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { PostgresQuizRepository } from '../src/domains/quiz/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('Quiz integration tests only permit localhost');
|
||||||
|
|
||||||
|
describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
|
||||||
|
const admin = new Pool({ connectionString: url });
|
||||||
|
const schema = `quiz_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
let pool: Pool;
|
||||||
|
let repo: PostgresQuizRepository;
|
||||||
|
|
||||||
|
const userA = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const userB = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const soloUser = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const outsider = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const couple = '55555555-5555-4555-8555-555555555555';
|
||||||
|
const soloCouple = '66666666-6666-4666-8666-666666666666';
|
||||||
|
const otherCouple = '77777777-7777-4777-8777-777777777777';
|
||||||
|
const userC = '88888888-8888-4888-8888-888888888888';
|
||||||
|
|
||||||
|
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 PostgresQuizRepository(isolated.toString());
|
||||||
|
|
||||||
|
const dir = new URL('../drizzle/', import.meta.url);
|
||||||
|
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
||||||
|
for (const name of files) {
|
||||||
|
const sql = await readFile(new URL(name, dir), '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),($3,$4),($5,$6)',
|
||||||
|
[couple, 'QUIZAAA1', soloCouple, 'QUIZAAA2', otherCouple, 'QUIZAAA3'],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`insert into profiles(id,couple_id) values
|
||||||
|
($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`,
|
||||||
|
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await pool.query('delete from quiz_answers');
|
||||||
|
await pool.query('delete from quiz_sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await repo.close();
|
||||||
|
await pool.end();
|
||||||
|
await admin.query(`drop schema "${schema}" cascade`);
|
||||||
|
await admin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to start a quiz without a couple or without a partner', async () => {
|
||||||
|
await expect(repo.startSession(randomUUID())).rejects.toMatchObject({ code: 'no_couple' });
|
||||||
|
await expect(repo.startSession(soloUser)).rejects.toMatchObject({ code: 'partner_required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a session with ten active questions and returns it idempotently', async () => {
|
||||||
|
const first = await repo.startSession(userA);
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
expect(first.session.status).toBe('active');
|
||||||
|
expect(first.session.questions).toHaveLength(10);
|
||||||
|
expect(first.session.score_percent).toBeNull();
|
||||||
|
expect(first.session.my_answers).toEqual([]);
|
||||||
|
expect(first.session.i_completed).toBe(false);
|
||||||
|
expect(first.session.partner_completed).toBe(false);
|
||||||
|
|
||||||
|
const second = await repo.startSession(userA);
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.session.id).toBe(first.session.id);
|
||||||
|
|
||||||
|
// The partner starting also resolves to the same active session.
|
||||||
|
const partnerView = await repo.startSession(userB);
|
||||||
|
expect(partnerView.created).toBe(false);
|
||||||
|
expect(partnerView.session.id).toBe(first.session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks the full happy path: both partners answer, session completes, score is exact', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const questionIds = session.questions.map((question) => question.id);
|
||||||
|
expect(questionIds).toHaveLength(10);
|
||||||
|
|
||||||
|
// Self-answers alternate 0/1 per question so a guess cannot accidentally match
|
||||||
|
// by coincidence. A guesses B's self-answer correctly on the first 6 questions
|
||||||
|
// and wrong on the rest; B guesses A's self-answer correctly on the first 3
|
||||||
|
// questions and wrong on the rest.
|
||||||
|
const bSelf = questionIds.map((_, index) => index % 2);
|
||||||
|
const aSelf = questionIds.map((_, index) => 1 - (index % 2));
|
||||||
|
const aGuess = bSelf.map((value, index) => (index < 6 ? value : 1 - value));
|
||||||
|
const bGuess = aSelf.map((value, index) => (index < 3 ? value : 1 - value));
|
||||||
|
|
||||||
|
const aAnswers = questionIds.map((questionId, index) => ({
|
||||||
|
question_id: questionId,
|
||||||
|
self_index: aSelf[index]!,
|
||||||
|
guess_index: aGuess[index]!,
|
||||||
|
}));
|
||||||
|
const bAnswers = questionIds.map((questionId, index) => ({
|
||||||
|
question_id: questionId,
|
||||||
|
self_index: bSelf[index]!,
|
||||||
|
guess_index: bGuess[index]!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const afterA = await repo.submitAnswers(userA, session.id, aAnswers);
|
||||||
|
expect(afterA.status).toBe('active');
|
||||||
|
expect(afterA.i_completed).toBe(true);
|
||||||
|
expect(afterA.partner_completed).toBe(false);
|
||||||
|
expect(afterA.my_answers).toHaveLength(10);
|
||||||
|
|
||||||
|
const afterB = await repo.submitAnswers(userB, session.id, bAnswers);
|
||||||
|
expect(afterB.status).toBe('completed');
|
||||||
|
expect(afterB.completed_at).not.toBeNull();
|
||||||
|
expect(afterB.i_completed).toBe(true);
|
||||||
|
expect(afterB.partner_completed).toBe(true);
|
||||||
|
|
||||||
|
// A guessed right (guess=1 matches B's self=1) on questions 0-5: 6 hits.
|
||||||
|
// B guessed right (guess=0 matches A's self=0) on questions 0-2: 3 hits.
|
||||||
|
// Total 9 hits out of 20 possible = 45%.
|
||||||
|
expect(afterB.score_percent).toBe(45);
|
||||||
|
|
||||||
|
const result = await repo.result(userA, session.id);
|
||||||
|
expect(result.score_percent).toBe(45);
|
||||||
|
expect(result.completed_at).not.toBeNull();
|
||||||
|
expect(result.questions).toHaveLength(10);
|
||||||
|
expect(result.questions[0]).toMatchObject({
|
||||||
|
my_self_index: aSelf[0],
|
||||||
|
my_guess_index: aGuess[0],
|
||||||
|
partner_self_index: bSelf[0],
|
||||||
|
partner_guess_index: bGuess[0],
|
||||||
|
i_guessed_right: true,
|
||||||
|
partner_guessed_right: true,
|
||||||
|
});
|
||||||
|
expect(result.questions[9]).toMatchObject({
|
||||||
|
i_guessed_right: false,
|
||||||
|
partner_guessed_right: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The partner's result view mirrors the same score with roles swapped.
|
||||||
|
const partnerResult = await repo.result(userB, session.id);
|
||||||
|
expect(partnerResult.score_percent).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an incomplete or mismatched answer set', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const questionIds = session.questions.map((question) => question.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.submitAnswers(userA, session.id, [
|
||||||
|
{ question_id: questionIds[0]!, self_index: 0, guess_index: 0 },
|
||||||
|
]),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.submitAnswers(userA, session.id, [
|
||||||
|
...questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })),
|
||||||
|
{ question_id: randomUUID(), self_index: 0, guess_index: 0 },
|
||||||
|
]),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
||||||
|
|
||||||
|
const options = session.questions[0]!.options;
|
||||||
|
await expect(
|
||||||
|
repo.submitAnswers(userA, session.id, questionIds.map((id) => ({
|
||||||
|
question_id: id,
|
||||||
|
self_index: options.length,
|
||||||
|
guess_index: 0,
|
||||||
|
}))),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates sessions to their own couple', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
await expect(repo.submitAnswers(outsider, session.id, [])).rejects.toMatchObject({ code: 'session_not_found' });
|
||||||
|
await expect(repo.result(outsider, session.id)).rejects.toMatchObject({ code: 'session_not_found' });
|
||||||
|
expect(await repo.currentSession(outsider)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the answers endpoint once the session has completed', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const questionIds = session.questions.map((question) => question.id);
|
||||||
|
const answers = questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
||||||
|
await repo.submitAnswers(userA, session.id, answers);
|
||||||
|
const completed = await repo.submitAnswers(userB, session.id, answers);
|
||||||
|
expect(completed.status).toBe('completed');
|
||||||
|
|
||||||
|
await expect(repo.submitAnswers(userA, session.id, answers)).rejects.toMatchObject({ code: 'session_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the result endpoint before the session has completed', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
await expect(repo.result(userA, session.id)).rejects.toMatchObject({ code: 'session_not_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the couple\'s most recent session from currentSession', async () => {
|
||||||
|
expect(await repo.currentSession(userA)).toBeNull();
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const current = await repo.currentSession(userB);
|
||||||
|
expect(current?.id).toBe(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the next session from never-used questions once a session completes', async () => {
|
||||||
|
const { session: first } = await repo.startSession(userA);
|
||||||
|
const firstQuestionIds = first.questions.map((question) => question.id);
|
||||||
|
expect(firstQuestionIds).toHaveLength(10);
|
||||||
|
|
||||||
|
const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
||||||
|
await repo.submitAnswers(userA, first.id, answers);
|
||||||
|
const completed = await repo.submitAnswers(userB, first.id, answers);
|
||||||
|
expect(completed.status).toBe('completed');
|
||||||
|
|
||||||
|
const { session: second, created } = await repo.startSession(userA);
|
||||||
|
expect(created).toBe(true);
|
||||||
|
const secondQuestionIds = second.questions.map((question) => question.id);
|
||||||
|
expect(secondQuestionIds).toHaveLength(10);
|
||||||
|
|
||||||
|
// The seeded pool has 36 active questions, so the never-used-first preference
|
||||||
|
// guarantees the second session's questions are wholly disjoint from the first.
|
||||||
|
const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id));
|
||||||
|
expect(overlap).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes selection to each couple independently', async () => {
|
||||||
|
const { session: coupleOneFirst } = await repo.startSession(userA);
|
||||||
|
const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id);
|
||||||
|
const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
||||||
|
await repo.submitAnswers(userA, coupleOneFirst.id, answers);
|
||||||
|
await repo.submitAnswers(userB, coupleOneFirst.id, answers);
|
||||||
|
|
||||||
|
// A different couple with no prior sessions should still be able to draw a
|
||||||
|
// fresh set of 10 questions regardless of what the first couple has used,
|
||||||
|
// since the never-used-for-them preference is scoped per couple.
|
||||||
|
const { session: otherSession, created } = await repo.startSession(outsider);
|
||||||
|
expect(created).toBe(true);
|
||||||
|
expect(otherSession.questions).toHaveLength(10);
|
||||||
|
|
||||||
|
const partnerView = await repo.startSession(userC);
|
||||||
|
expect(partnerView.created).toBe(false);
|
||||||
|
expect(partnerView.session.id).toBe(otherSession.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthIdentity } from '../src/auth.js';
|
||||||
|
import type { QuizRepository } from '../src/domains/quiz/repository.js';
|
||||||
|
import { registerQuizRoutes } from '../src/domains/quiz/routes.js';
|
||||||
|
import { QuizError, type QuizSessionPayload } from '../src/domains/quiz/types.js';
|
||||||
|
|
||||||
|
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||||
|
const sessionId = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const questionId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
|
||||||
|
const session: QuizSessionPayload = {
|
||||||
|
id: sessionId,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2026-08-27T00:00:00.000Z',
|
||||||
|
completed_at: null,
|
||||||
|
score_percent: null,
|
||||||
|
questions: [
|
||||||
|
{ id: questionId, prompt_self: 'Self prompt', prompt_guess: 'Guess prompt', options: ['A', 'B'] },
|
||||||
|
],
|
||||||
|
my_answers: [],
|
||||||
|
partner_completed: false,
|
||||||
|
i_completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(overrides: Partial<QuizRepository> = {}) {
|
||||||
|
const repository: QuizRepository = {
|
||||||
|
startSession: vi.fn(async () => ({ session, created: true })),
|
||||||
|
currentSession: vi.fn(async () => session),
|
||||||
|
submitAnswers: vi.fn(async () => session),
|
||||||
|
result: vi.fn(async () => ({
|
||||||
|
score_percent: 100,
|
||||||
|
completed_at: '2026-08-27T00:00:00.000Z',
|
||||||
|
questions: [],
|
||||||
|
})),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||||
|
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||||
|
registerQuizRoutes(app, repository);
|
||||||
|
return { app, repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||||
|
return app.request(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('quiz routes', () => {
|
||||||
|
it('starts a session and returns 201 when a new one is created', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/quiz/sessions');
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await response.json()).toEqual({ session });
|
||||||
|
expect(repository.startSession).toHaveBeenCalledWith(identity.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 when starting resolves to an existing active session', async () => {
|
||||||
|
const { app } = setup({ startSession: vi.fn(async () => ({ session, created: false })) });
|
||||||
|
const response = await post(app, '/v1/quiz/sessions');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps no_couple and partner_required to 409', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
startSession: vi.fn(async () => { throw new QuizError('no_couple', 'Join a couple first', 409); }),
|
||||||
|
});
|
||||||
|
const response = await post(app, '/v1/quiz/sessions');
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toMatchObject({ error: { code: 'no_couple' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the current session, including null when none exists', async () => {
|
||||||
|
const { app } = setup({ currentSession: vi.fn(async () => null) });
|
||||||
|
const response = await app.request('/v1/quiz/sessions/current');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ session: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed session id on the answers route', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/quiz/sessions/not-a-uuid/answers', {
|
||||||
|
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(repository.submitAnswers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty or malformed answer body', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers: [] })).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {})).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
|
||||||
|
answers: [{ question_id: questionId, self_index: 0, guess_index: 1, extra: true }],
|
||||||
|
})).status).toBe(400);
|
||||||
|
expect(repository.submitAnswers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits valid answers and surfaces the updated session', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const answers = [{ question_id: questionId, self_index: 0, guess_index: 1 }];
|
||||||
|
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(repository.submitAnswers).toHaveBeenCalledWith(identity.id, sessionId, answers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps session errors to their status codes', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
submitAnswers: vi.fn(async () => { throw new QuizError('session_completed', 'Already finished', 409); }),
|
||||||
|
});
|
||||||
|
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
|
||||||
|
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toMatchObject({ error: { code: 'session_completed' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the result payload for a completed session', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ score_percent: 100 });
|
||||||
|
expect(repository.result).toHaveBeenCalledWith(identity.id, sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps session_not_completed to 409 on the result route', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
result: vi.fn(async () => { throw new QuizError('session_not_completed', 'Not finished yet', 409); }),
|
||||||
|
});
|
||||||
|
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed session id on the result route', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request('/v1/quiz/sessions/not-a-uuid/result');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(repository.result).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeQuizScore, type QuizAnswerPair } from '../src/domains/quiz/service.js';
|
||||||
|
|
||||||
|
function pair(overrides: Partial<QuizAnswerPair> = {}): QuizAnswerPair {
|
||||||
|
return {
|
||||||
|
questionId: 'q1',
|
||||||
|
aSelfIndex: 0,
|
||||||
|
aGuessIndex: 0,
|
||||||
|
bSelfIndex: 0,
|
||||||
|
bGuessIndex: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeQuizScore', () => {
|
||||||
|
it('scores 100% when every guess matches the partner self-answer in both directions', () => {
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 1 }),
|
||||||
|
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 3, bGuessIndex: 0 }),
|
||||||
|
];
|
||||||
|
const { scorePercent } = computeQuizScore(pairs);
|
||||||
|
expect(scorePercent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores 0% when nobody guesses their partner correctly', () => {
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 2, bGuessIndex: 3 }),
|
||||||
|
];
|
||||||
|
expect(computeQuizScore(pairs).scorePercent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts each direction independently, so a half-right couple lands at 50%', () => {
|
||||||
|
// A guesses B correctly, but B guesses A incorrectly: one hit out of two possible.
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 0 }),
|
||||||
|
];
|
||||||
|
const result = computeQuizScore(pairs);
|
||||||
|
expect(result.scorePercent).toBe(50);
|
||||||
|
expect(result.perQuestion[0]).toMatchObject({ aGuessedRight: true, bGuessedRight: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds to the nearest whole percent', () => {
|
||||||
|
// 1 hit out of 6 possible (3 questions * 2 directions) = 16.67%, rounds to 17.
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
// aGuess (0) matches bSelf (0): one hit. bGuess (3) does not match aSelf (1).
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||||
|
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||||
|
pair({ questionId: 'q3', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||||
|
];
|
||||||
|
expect(computeQuizScore(pairs).scorePercent).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0% for an empty question set instead of dividing by zero', () => {
|
||||||
|
expect(computeQuizScore([]).scorePercent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,12 @@ EXPO_PUBLIC_API_URL=http://127.0.0.1:3003
|
|||||||
# commit them. The test user must be disposable, onboarding-complete, and have a
|
# commit them. The test user must be disposable, onboarding-complete, and have a
|
||||||
# matching profile in the standalone non-production PostgreSQL database.
|
# matching profile in the standalone non-production PostgreSQL database.
|
||||||
|
|
||||||
|
# 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)
|
# Sentry error tracking (optional - get DSN from https://sentry.io)
|
||||||
EXPO_PUBLIC_SENTRY_DSN=
|
EXPO_PUBLIC_SENTRY_DSN=
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 47
|
versionCode 49
|
||||||
versionName "1.0.5"
|
versionName "1.0.7"
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -182,3 +182,53 @@ dependencies {
|
|||||||
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
apply plugin: 'com.google.gms.google-services'
|
||||||
apply plugin: 'com.google.firebase.crashlytics'
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="com.android.vending.BILLING"/>
|
<uses-permission android:name="com.android.vending.BILLING"/>
|
||||||
<queries>
|
<queries>
|
||||||
|
<package android:name="com.instagram.android"/>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<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="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_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.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-----
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-----
"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{"keyid":"main","alg":"rsa-v1_5-sha256"}"/>
|
||||||
|
<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_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="{"expo-channel-name":"production","expo-app-id":"9a66f3c2-fda7-416f-87e2-6db833b32e9d","xprem-branch":""}"/>
|
||||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
<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">
|
<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>
|
<intent-filter>
|
||||||
@@ -43,6 +49,12 @@
|
|||||||
<data android:scheme="com.sauci.app"/>
|
<data android:scheme="com.sauci.app"/>
|
||||||
<data android:scheme="exp+sauci"/>
|
<data android:scheme="exp+sauci"/>
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
|
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -3,4 +3,5 @@
|
|||||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
<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="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="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
|
||||||
|
<string name="expo_runtime_version">1.0.7</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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': '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
+43
-4
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Sauci",
|
"name": "Sauci",
|
||||||
"slug": "sauci",
|
"slug": "sauci",
|
||||||
"version": "1.0.5",
|
"version": "1.0.7",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
@@ -16,12 +16,18 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
|
"associatedDomains": [
|
||||||
|
"applinks:sauci.app"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
"entitlements": {
|
"entitlements": {
|
||||||
"com.apple.security.application-groups": [
|
"com.apple.security.application-groups": [
|
||||||
"group.com.sauci.app"
|
"group.com.sauci.app"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"buildNumber": "43",
|
"buildNumber": "46",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"googleServicesFile": "./GoogleService-Info.plist",
|
"googleServicesFile": "./GoogleService-Info.plist",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
@@ -183,7 +189,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 47,
|
"versionCode": 49,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -191,6 +197,23 @@
|
|||||||
"android.permission.USE_BIOMETRIC",
|
"android.permission.USE_BIOMETRIC",
|
||||||
"android.permission.USE_FINGERPRINT",
|
"android.permission.USE_FINGERPRINT",
|
||||||
"com.android.vending.BILLING"
|
"com.android.vending.BILLING"
|
||||||
|
],
|
||||||
|
"intentFilters": [
|
||||||
|
{
|
||||||
|
"action": "VIEW",
|
||||||
|
"autoVerify": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"scheme": "https",
|
||||||
|
"host": "sauci.app",
|
||||||
|
"pathPrefix": "/join"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": [
|
||||||
|
"BROWSABLE",
|
||||||
|
"DEFAULT"
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
@@ -208,6 +231,10 @@
|
|||||||
"buildToolsVersion": "36.0.0",
|
"buildToolsVersion": "36.0.0",
|
||||||
"compileSdkVersion": 36,
|
"compileSdkVersion": 36,
|
||||||
"targetSdkVersion": 36,
|
"targetSdkVersion": 36,
|
||||||
|
"blockedPermissions": [
|
||||||
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
|
"android.permission.READ_MEDIA_VIDEO"
|
||||||
|
],
|
||||||
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||||
"useLegacyPackaging": false,
|
"useLegacyPackaging": false,
|
||||||
"manifestPlaceholders": {
|
"manifestPlaceholders": {
|
||||||
@@ -271,7 +298,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withWidgetExported.js",
|
"./plugins/withWidgetExported.js",
|
||||||
"./plugins/withNativeWidget.js"
|
"./plugins/withNativeWidget.js",
|
||||||
|
[
|
||||||
|
"react-native-share",
|
||||||
|
{
|
||||||
|
"ios": [
|
||||||
|
"instagram",
|
||||||
|
"instagram-stories"
|
||||||
|
],
|
||||||
|
"android": [
|
||||||
|
"com.instagram.android"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import * as Haptics from 'expo-haptics';
|
|||||||
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||||
import { GradientBackground } from '../../src/components/ui';
|
import { GradientBackground } from '../../src/components/ui';
|
||||||
import { Paywall } from '../../src/components/paywall';
|
import { Paywall } from '../../src/components/paywall';
|
||||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
|
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard, QuizTile } from '../../src/components/discovery';
|
||||||
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
import { colors, spacing, typography } from '../../src/theme';
|
import { colors, spacing, typography } from '../../src/theme';
|
||||||
import type { QuestionPack, Category } from '../../src/types';
|
import type { QuestionPack, Category } from '../../src/types';
|
||||||
|
import { pickRecommendedPacks, RECOMMENDED_PACKS_LABEL } from '../../src/features/home/recommendedPacks';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 600;
|
const MAX_CONTENT_WIDTH = 600;
|
||||||
|
|
||||||
@@ -112,6 +113,13 @@ export default function DiscoveryScreen() {
|
|||||||
[packs, categories]
|
[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)
|
// Check if user has no packs enabled (empty state)
|
||||||
const hasNoPacks = packs.length === 0;
|
const hasNoPacks = packs.length === 0;
|
||||||
|
|
||||||
@@ -166,6 +174,20 @@ export default function DiscoveryScreen() {
|
|||||||
{/* Dares */}
|
{/* Dares */}
|
||||||
<DaresTile delay={200} />
|
<DaresTile delay={200} />
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Content Rows by Category */}
|
||||||
{packsByCategory.map((group, index) => (
|
{packsByCategory.map((group, index) => (
|
||||||
<ContentRow
|
<ContentRow
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { Events } from '../../src/lib/analytics';
|
|||||||
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
||||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||||
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
|
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 }[] = [
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
{ value: 'male', label: 'Male', icon: 'male' },
|
{ value: 'male', label: 'Male', icon: 'male' },
|
||||||
@@ -43,8 +43,6 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
|||||||
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
|
{ 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 }[] = [
|
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
||||||
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
|
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
|
||||||
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
|
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
|
||||||
@@ -94,6 +92,13 @@ export default function OnboardingScreen() {
|
|||||||
Events.onboardingStageComplete('avatar');
|
Events.onboardingStageComplete('avatar');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAvatarSkip = () => {
|
||||||
|
setError(null);
|
||||||
|
setStage('name');
|
||||||
|
Events.avatarSkipped();
|
||||||
|
Events.onboardingStageComplete('avatar');
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameSubmit = () => {
|
const handleNameSubmit = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Please enter your name');
|
setError('Please enter your name');
|
||||||
@@ -280,6 +285,13 @@ export default function OnboardingScreen() {
|
|||||||
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
|
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
|
||||||
Continue
|
Continue
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
|
<Pressable
|
||||||
|
style={styles.skipButton}
|
||||||
|
onPress={handleAvatarSkip}
|
||||||
|
testID="avatar-skip-button"
|
||||||
|
>
|
||||||
|
<Text style={styles.skipText}>Skip for now</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,26 +16,96 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { useAuthStore } from "../../src/store";
|
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||||
import { coupleApi } from "../../src/lib/coupleApi";
|
import { coupleApi } from "../../src/lib/coupleApi";
|
||||||
import { getPairingError } from "../../src/lib/errors";
|
import { getPairingError } from "../../src/lib/errors";
|
||||||
|
import { buildInviteShareMessage } from "../../src/lib/inviteShareCopy";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
import { router } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
|
||||||
|
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
||||||
|
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||||
|
|
||||||
export default function PairingScreen() {
|
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 [inviteCode, setInviteCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [wasPrefilled, setWasPrefilled] = useState(false);
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (couple && partner) {
|
if (couple && partner) {
|
||||||
|
void useMatchStore.getState().fetchMatches(true);
|
||||||
router.replace("/(app)");
|
router.replace("/(app)");
|
||||||
}
|
}
|
||||||
}, [couple, partner]);
|
}, [couple, partner]);
|
||||||
|
|
||||||
|
// One-time prefill: apply a code carried by a join link (route param), then
|
||||||
|
// a stashed code from before sign-in, and only otherwise offer a clipboard
|
||||||
|
// match. Never auto-submits; the user still taps "Join Partner".
|
||||||
|
useEffect(() => {
|
||||||
|
if (couple) return; // only relevant to the unpaired join flow
|
||||||
|
if (prefillAttempted) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const applyPrefill = async (code: string) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setInviteCode(code);
|
||||||
|
setWasPrefilled(true);
|
||||||
|
Events.pairingCodePrefilled();
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
||||||
|
if (isValidInviteCode(routeCode)) {
|
||||||
|
await applyPrefill(routeCode);
|
||||||
|
await clearPendingInviteCode();
|
||||||
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stashedCode = await getPendingInviteCode();
|
||||||
|
if (stashedCode) {
|
||||||
|
await applyPrefill(stashedCode);
|
||||||
|
await clearPendingInviteCode();
|
||||||
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipboardCode = await checkClipboardForInviteCode();
|
||||||
|
if (clipboardCode && !cancelled) {
|
||||||
|
setClipboardOfferCode(clipboardCode);
|
||||||
|
}
|
||||||
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [couple, params.code, prefillAttempted]);
|
||||||
|
|
||||||
|
const acceptClipboardOffer = () => {
|
||||||
|
if (!clipboardOfferCode) return;
|
||||||
|
setInviteCode(clipboardOfferCode);
|
||||||
|
setWasPrefilled(true);
|
||||||
|
Events.inviteLinkOpened("clipboard");
|
||||||
|
Events.pairingCodePrefilled();
|
||||||
|
setClipboardOfferCode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissClipboardOffer = () => {
|
||||||
|
setClipboardOfferCode(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
||||||
// be added later without coupling product data back to Supabase.
|
// be added later without coupling product data back to Supabase.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,6 +150,9 @@ export default function PairingScreen() {
|
|||||||
|
|
||||||
await fetchUser(); // Refresh user to get couple_id
|
await fetchUser(); // Refresh user to get couple_id
|
||||||
await fetchCouple(); // Fetch couple data
|
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();
|
Events.coupleJoined();
|
||||||
|
|
||||||
Alert.alert("Success", "You are now paired!", [
|
Alert.alert("Success", "You are now paired!", [
|
||||||
@@ -100,8 +173,13 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInviteCodeChange = (value: string) => {
|
||||||
|
setWasPrefilled(false);
|
||||||
|
setInviteCode(value);
|
||||||
|
};
|
||||||
|
|
||||||
const shareMessage = couple?.invite_code
|
const shareMessage = couple?.invite_code
|
||||||
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`
|
? buildInviteShareMessage(couple.invite_code, sealedCount)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const shareCode = async () => {
|
const shareCode = async () => {
|
||||||
@@ -209,6 +287,21 @@ export default function PairingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</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 */}
|
{/* Code Card */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(300).duration(500)}
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
@@ -338,6 +431,37 @@ export default function PairingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Clipboard invite code offer */}
|
||||||
|
{clipboardOfferCode && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(400)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<View style={styles.clipboardOffer} testID="clipboard-invite-offer">
|
||||||
|
<Ionicons name="clipboard-outline" size={18} color={colors.primary} />
|
||||||
|
<Text style={styles.clipboardOfferText}>
|
||||||
|
Use code {clipboardOfferCode} from your clipboard?
|
||||||
|
</Text>
|
||||||
|
<View style={styles.clipboardOfferActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={dismissClipboardOffer}
|
||||||
|
style={styles.clipboardOfferButton}
|
||||||
|
testID="clipboard-invite-offer-dismiss"
|
||||||
|
>
|
||||||
|
<Text style={styles.clipboardOfferDismissText}>Not now</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={acceptClipboardOffer}
|
||||||
|
style={[styles.clipboardOfferButton, styles.clipboardOfferButtonPrimary]}
|
||||||
|
testID="clipboard-invite-offer-accept"
|
||||||
|
>
|
||||||
|
<Text style={styles.clipboardOfferAcceptText}>Use code</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Join Card */}
|
{/* Join Card */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(300).duration(500)}
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
@@ -346,14 +470,20 @@ export default function PairingScreen() {
|
|||||||
<Text style={styles.sectionTitle}>Have a code?</Text>
|
<Text style={styles.sectionTitle}>Have a code?</Text>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, wasPrefilled && styles.inputPrefilled]}
|
||||||
placeholder="Enter invite code"
|
placeholder="Enter invite code"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChangeText={setInviteCode}
|
onChangeText={handleInviteCodeChange}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
|
testID="invite-code-input"
|
||||||
/>
|
/>
|
||||||
|
{wasPrefilled && (
|
||||||
|
<Text style={styles.prefilledCaption}>
|
||||||
|
Code applied from your invite link
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleJoinCouple}
|
onPress={handleJoinCouple}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -436,6 +566,50 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
|
inputPrefilled: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
prefilledCaption: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.primary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: -spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
clipboardOffer: {
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
clipboardOfferText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
clipboardOfferActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
clipboardOfferButton: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
clipboardOfferButtonPrimary: {
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
},
|
||||||
|
clipboardOfferDismissText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
clipboardOfferAcceptText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
// ...
|
// ...
|
||||||
dividerLine: {
|
dividerLine: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -494,6 +668,11 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
maxWidth: 280,
|
maxWidth: 280,
|
||||||
},
|
},
|
||||||
|
sealedCountText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,232 +1,5 @@
|
|||||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
import { QuizScreen } from "../../src/features/quiz";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
|
||||||
import { GradientBackground } from "../../src/components/ui";
|
|
||||||
import { colors, spacing, radius, typography, animations } from "../../src/theme";
|
|
||||||
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
|
|
||||||
|
|
||||||
const ACCENT_COLOR = colors.premium.rose;
|
export default function QuizRoute() {
|
||||||
|
return <QuizScreen />;
|
||||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
||||||
|
|
||||||
export default function QuizScreen() {
|
|
||||||
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
|
|
||||||
useFeatureInterest("quiz");
|
|
||||||
|
|
||||||
const scale = useSharedValue(1);
|
|
||||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ scale: scale.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handlePressIn = () => {
|
|
||||||
scale.value = withSpring(0.96, animations.spring);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePressOut = () => {
|
|
||||||
scale.value = withSpring(1, animations.spring);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GradientBackground>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.duration(600).springify()}
|
|
||||||
style={styles.content}
|
|
||||||
>
|
|
||||||
{/* Title section */}
|
|
||||||
<Text style={styles.label}>CONNECTION</Text>
|
|
||||||
<Text style={styles.title}>Quiz</Text>
|
|
||||||
|
|
||||||
{/* Decorative separator */}
|
|
||||||
<View style={styles.separator}>
|
|
||||||
<View style={styles.separatorLine} />
|
|
||||||
<View style={styles.separatorDiamond} />
|
|
||||||
<View style={styles.separatorLine} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Coming soon badge */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.delay(300).duration(400)}
|
|
||||||
style={styles.badge}
|
|
||||||
>
|
|
||||||
<Text style={styles.badgeText}>ARRIVING SOON</Text>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Text style={styles.description}>
|
|
||||||
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Feature hints */}
|
|
||||||
<View style={styles.features}>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Learn what makes each other tick</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Deepen your bond</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Track your journey</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom teaser */}
|
|
||||||
<Text style={styles.teaser}>Something special is being crafted for you</Text>
|
|
||||||
|
|
||||||
{/* Interest button - always reserve space to prevent layout shift */}
|
|
||||||
<View style={styles.interestButtonContainer}>
|
|
||||||
{isAuthenticated && !isLoading && (
|
|
||||||
<Animated.View entering={FadeIn.delay(500).duration(400)}>
|
|
||||||
<AnimatedPressable
|
|
||||||
onPress={toggleInterest}
|
|
||||||
onPressIn={handlePressIn}
|
|
||||||
onPressOut={handlePressOut}
|
|
||||||
disabled={isToggling}
|
|
||||||
style={[
|
|
||||||
styles.interestButton,
|
|
||||||
isInterested && styles.interestButtonActive,
|
|
||||||
animatedButtonStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isToggling ? (
|
|
||||||
<ActivityIndicator size="small" color={ACCENT_COLOR} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons
|
|
||||||
name={isInterested ? "checkmark-circle" : "notifications-outline"}
|
|
||||||
size={16}
|
|
||||||
color={ACCENT_COLOR}
|
|
||||||
/>
|
|
||||||
<Text style={styles.interestButtonText}>
|
|
||||||
{isInterested ? "You're on the list!" : "I'm interested"}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatedPressable>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</GradientBackground>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingTop: 60,
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
...typography.caption1,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 3,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
...typography.largeTitle,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginVertical: spacing.lg,
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
separatorLine: {
|
|
||||||
flex: 1,
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: 'rgba(232, 164, 174, 0.3)',
|
|
||||||
},
|
|
||||||
separatorDiamond: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
transform: [{ rotate: '45deg' }],
|
|
||||||
marginHorizontal: spacing.md,
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
badge: {
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
interestButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm + 2,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
interestButtonActive: {
|
|
||||||
backgroundColor: colors.background,
|
|
||||||
borderColor: colors.primary,
|
|
||||||
},
|
|
||||||
interestButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
badgeText: {
|
|
||||||
...typography.caption2,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 2,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
...typography.body,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
featureItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
featureText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.text,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
teaser: {
|
|
||||||
...typography.footnote,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
color: colors.textTertiary,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
interestButtonContainer: {
|
|
||||||
marginTop: spacing.xl,
|
|
||||||
minHeight: 44,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -46,6 +46,9 @@ import { useAuthStore } from "../src/store";
|
|||||||
import { authClient } from "../src/lib/authClient";
|
import { authClient } from "../src/lib/authClient";
|
||||||
import { colors, spacing, radius, typography } from "../src/theme";
|
import { colors, spacing, radius, typography } from "../src/theme";
|
||||||
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||||
|
import { parseInviteLinkCode } from "../src/lib/inviteLink";
|
||||||
|
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||||
|
import { needsOnboarding } from "../src/constants/onboarding";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -114,9 +117,23 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
// Handle initial URL if app was opened via deep link
|
// Handle initial URL if app was opened via deep link
|
||||||
const handleDeepLink = async (url: string | null) => {
|
const handleDeepLink = async (url: string | null) => {
|
||||||
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
|
||||||
if (!url) return;
|
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) {
|
||||||
|
Events.inviteLinkOpened(inviteLink.source);
|
||||||
|
await stashPendingInviteCode(inviteLink.code);
|
||||||
|
|
||||||
|
const { isAuthenticated, user } = useAuthStore.getState();
|
||||||
|
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||||
|
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
||||||
|
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse the URL to extract auth parameters
|
// Parse the URL to extract auth parameters
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
@@ -185,13 +202,11 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
// Get initial URL
|
// Get initial URL
|
||||||
Linking.getInitialURL().then((url) => {
|
Linking.getInitialURL().then((url) => {
|
||||||
console.log('[DeepLink] getInitialURL returned:', url);
|
|
||||||
handleDeepLink(url);
|
handleDeepLink(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for new deep links while app is open
|
// Listen for new deep links while app is open
|
||||||
const linkingSubscription = Linking.addEventListener("url", (event) => {
|
const linkingSubscription = Linking.addEventListener("url", (event) => {
|
||||||
console.log('[DeepLink] URL event listener triggered with:', event.url);
|
|
||||||
handleDeepLink(event.url);
|
handleDeepLink(event.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
import { needsOnboarding } from "../src/constants/onboarding";
|
import { needsOnboarding } from "../src/constants/onboarding";
|
||||||
|
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
|
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
||||||
|
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
||||||
|
|
||||||
|
// Check once, after auth resolves, whether an invite code is waiting to be
|
||||||
|
// applied (e.g. the user tapped a join link before signing in).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !isAuthenticated || user?.couple_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
getPendingInviteCode().then((code) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setHasPendingInvite(!!code);
|
||||||
|
setPendingCodeChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isLoading, isAuthenticated, user?.couple_id]);
|
||||||
|
|
||||||
// Show loading state while auth is being determined
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -25,6 +48,26 @@ export default function Index() {
|
|||||||
return <Redirect href="/(app)/onboarding" />;
|
return <Redirect href="/(app)/onboarding" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A couple already exists for this user; no invite hand-off is relevant.
|
||||||
|
if (user?.couple_id) {
|
||||||
|
return <Redirect href="/(app)" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the one-time pending invite code check before deciding the
|
||||||
|
// route, so a stashed code from a join link is applied without an extra
|
||||||
|
// trip through the home screen.
|
||||||
|
if (!pendingCodeChecked) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingInvite) {
|
||||||
|
return <Redirect href="/(app)/pairing" />;
|
||||||
|
}
|
||||||
|
|
||||||
// Go to home/dashboard - users can pair from within the app
|
// Go to home/dashboard - users can pair from within the app
|
||||||
// No forced redirect to pairing for users without a couple
|
// No forced redirect to pairing for users without a couple
|
||||||
return <Redirect href="/(app)" />;
|
return <Redirect href="/(app)" />;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,19 +7,24 @@
|
|||||||
"development": {
|
"development": {
|
||||||
"node": "20.19.4",
|
"node": "20.19.4",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal",
|
||||||
|
"env": {
|
||||||
|
"RELEASE_CHANNEL": "development"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"node": "20.19.4",
|
"node": "20.19.4",
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
|
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app",
|
||||||
|
"RELEASE_CHANNEL": "staging"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"node": "20.19.4",
|
"node": "20.19.4",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
|
"RELEASE_CHANNEL": "production",
|
||||||
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
|
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
|
||||||
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
||||||
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
|
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ PODS:
|
|||||||
- GoogleUtilities/Network (~> 8.1)
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
- nanopb (~> 3.30910.0)
|
- nanopb (~> 3.30910.0)
|
||||||
- GoogleDataTransport (10.1.0):
|
- GoogleDataTransport (10.1.1):
|
||||||
- nanopb (~> 3.30910.0)
|
- nanopb (~> 3.30910.0)
|
||||||
- PromisesObjC (~> 2.4)
|
- PromisesObjC (~> 2.4)
|
||||||
- GoogleSignIn (9.1.0):
|
- GoogleSignIn (9.1.0):
|
||||||
@@ -3236,6 +3236,34 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- SocketRocket
|
- SocketRocket
|
||||||
- Yoga
|
- 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):
|
- RNSVG (15.12.1):
|
||||||
- boost
|
- boost
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
@@ -3517,6 +3545,7 @@ DEPENDENCIES:
|
|||||||
- RNPurchases (from `../../../node_modules/react-native-purchases`)
|
- RNPurchases (from `../../../node_modules/react-native-purchases`)
|
||||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||||
- RNScreens (from `../../../node_modules/react-native-screens`)
|
- RNScreens (from `../../../node_modules/react-native-screens`)
|
||||||
|
- RNShare (from `../../../node_modules/react-native-share`)
|
||||||
- RNSVG (from `../../../node_modules/react-native-svg`)
|
- RNSVG (from `../../../node_modules/react-native-svg`)
|
||||||
- RNWorklets (from `../../../node_modules/react-native-worklets`)
|
- RNWorklets (from `../../../node_modules/react-native-worklets`)
|
||||||
- SocketRocket (~> 0.7.1)
|
- SocketRocket (~> 0.7.1)
|
||||||
@@ -3792,6 +3821,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/react-native-reanimated"
|
:path: "../node_modules/react-native-reanimated"
|
||||||
RNScreens:
|
RNScreens:
|
||||||
:path: "../../../node_modules/react-native-screens"
|
:path: "../../../node_modules/react-native-screens"
|
||||||
|
RNShare:
|
||||||
|
:path: "../../../node_modules/react-native-share"
|
||||||
RNSVG:
|
RNSVG:
|
||||||
:path: "../../../node_modules/react-native-svg"
|
:path: "../../../node_modules/react-native-svg"
|
||||||
RNWorklets:
|
RNWorklets:
|
||||||
@@ -3854,7 +3885,7 @@ SPEC CHECKSUMS:
|
|||||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||||
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
|
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539
|
||||||
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||||
@@ -3944,6 +3975,7 @@ SPEC CHECKSUMS:
|
|||||||
RNPurchases: ae1f8f522674a7938dd26eba6c103c40d049dfee
|
RNPurchases: ae1f8f522674a7938dd26eba6c103c40d049dfee
|
||||||
RNReanimated: 45bfba06751010ae2f6a1cd8b9f618ac4ce1a12c
|
RNReanimated: 45bfba06751010ae2f6a1cd8b9f618ac4ce1a12c
|
||||||
RNScreens: 2e9c41cd099b1ca50136af8d57c3594214d0086a
|
RNScreens: 2e9c41cd099b1ca50136af8d57c3594214d0086a
|
||||||
|
RNShare: 61cad1e65b8e3788c97ee7c5cac4bcbb25a8df40
|
||||||
RNSVG: 94a1be05fab4043354bcf7104f0f9b0e2231ef05
|
RNSVG: 94a1be05fab4043354bcf7104f0f9b0e2231ef05
|
||||||
RNWorklets: 09a47bd92d3da6e00420a060fcc86e23b745e980
|
RNWorklets: 09a47bd92d3da6e00420a060fcc86e23b745e980
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
|
|||||||
@@ -570,7 +570,7 @@
|
|||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.7;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.7;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -772,7 +772,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 43;
|
CURRENT_PROJECT_VERSION = 46;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -782,7 +782,7 @@
|
|||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.7;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
||||||
@@ -808,7 +808,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 43;
|
CURRENT_PROJECT_VERSION = 46;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -818,7 +818,7 @@
|
|||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.7;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
|
|||||||
+114
-107
@@ -1,111 +1,118 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleAllowMixedLocalizations</key>
|
<key>CFBundleAllowMixedLocalizations</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Sauci</string>
|
<string>Sauci</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.5</string>
|
<string>1.0.7</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>app.sauci</string>
|
<string>app.sauci</string>
|
||||||
<string>com.sauci.app</string>
|
<string>com.sauci.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>exp+sauci</string>
|
<string>exp+sauci</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237</string>
|
<string>com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>43</string>
|
<string>46</string>
|
||||||
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<string>12.0</string>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<true/>
|
<array>
|
||||||
<key>NSAppTransportSecurity</key>
|
<string>instagram</string>
|
||||||
<dict>
|
<string>instagram-stories</string>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
</array>
|
||||||
<false/>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<string>12.0</string>
|
||||||
<true/>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
</dict>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
<dict>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
<false/>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<string>Sauci uses your microphone to record audio when you capture videos to share privately with your partner.</string>
|
<true/>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
</dict>
|
||||||
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
<key>NSCameraUsageDescription</key>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
||||||
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<key>NSUserActivityTypes</key>
|
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
||||||
<array>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<string>Sauci uses your microphone to record audio when you capture videos to share privately with your partner.</string>
|
||||||
</array>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<key>RCTNewArchEnabled</key>
|
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
||||||
<true/>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<key>UIBackgroundModes</key>
|
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
||||||
<array>
|
<key>NSUserActivityTypes</key>
|
||||||
<string>remote-notification</string>
|
<array>
|
||||||
</array>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
</array>
|
||||||
<string>SplashScreen</string>
|
<key>RCTNewArchEnabled</key>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<true/>
|
||||||
<array>
|
<key>UIBackgroundModes</key>
|
||||||
<string>arm64</string>
|
<array>
|
||||||
</array>
|
<string>remote-notification</string>
|
||||||
<key>UIRequiresFullScreen</key>
|
</array>
|
||||||
<false/>
|
<key>UILaunchStoryboardName</key>
|
||||||
<key>UIStatusBarStyle</key>
|
<string>SplashScreen</string>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<array>
|
||||||
<array>
|
<string>arm64</string>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
</array>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<key>UIRequiresFullScreen</key>
|
||||||
</array>
|
<false/>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UIStatusBarStyle</key>
|
||||||
<array>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<array>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<string>Automatic</string>
|
<array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<false/>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</dict>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIUserInterfaceStyle</key>
|
||||||
|
<string>Automatic</string>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>Default</string>
|
<string>Default</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:sauci.app</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.sauci.app</string>
|
<string>group.com.sauci.app</string>
|
||||||
|
|||||||
@@ -4,9 +4,49 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>EXUpdatesCheckOnLaunch</key>
|
<key>EXUpdatesCheckOnLaunch</key>
|
||||||
<string>ALWAYS</string>
|
<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>
|
<key>EXUpdatesEnabled</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>EXUpdatesLaunchWaitMs</key>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -18,7 +18,15 @@
|
|||||||
"release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
|
"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": "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: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": {
|
"dependencies": {
|
||||||
"@bacons/apple-targets": "^0.1.6",
|
"@bacons/apple-targets": "^0.1.6",
|
||||||
@@ -62,6 +70,7 @@
|
|||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sharing": "~14.0.8",
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.20",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
@@ -72,6 +81,7 @@
|
|||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-share": "^12.3.1",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-view-shot": "^4.0.3",
|
"react-native-view-shot": "^4.0.3",
|
||||||
@@ -87,6 +97,7 @@
|
|||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
|
"eoas": "^3.1.3",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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 { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
|
||||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||||
|
import { useAuthStore } from "@/store";
|
||||||
import * as swipeService from "@/features/swipe/services/swipeService";
|
import * as swipeService from "@/features/swipe/services/swipeService";
|
||||||
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
|
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
|
// 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.
|
// would retrigger the fetch effect forever and the screen would never settle.
|
||||||
jest.mock("@/store", () => {
|
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 packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
||||||
const packState = { ...packs, invalidatePacks: jest.fn() };
|
const packState = { ...packs, invalidatePacks: jest.fn() };
|
||||||
const responses = { invalidateResponses: jest.fn() };
|
const responses = { invalidateResponses: jest.fn() };
|
||||||
const useAuthStore = () => auth;
|
const useAuthStore = () => auth;
|
||||||
useAuthStore.getState = () => auth.user;
|
useAuthStore.getState = () => auth.user;
|
||||||
|
useAuthStore.setState = jest.fn();
|
||||||
const usePacksStore = () => packs;
|
const usePacksStore = () => packs;
|
||||||
usePacksStore.getState = () => packState;
|
usePacksStore.getState = () => packState;
|
||||||
const useResponsesStore = () => responses;
|
const useResponsesStore = () => responses;
|
||||||
@@ -146,6 +148,19 @@ describe("useSwipeScreen daily limit", () => {
|
|||||||
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
|
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 () => {
|
it("refreshes the shared streak once the answer lands", async () => {
|
||||||
const { result } = await renderReady();
|
const { result } = await renderReady();
|
||||||
mockFetchStreak.mockClear();
|
mockFetchStreak.mockClear();
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import { ActionSheetIOS, Alert, Platform } from 'react-native';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
|
||||||
|
|
||||||
|
jest.mock('expo-file-system/legacy', () => ({
|
||||||
|
getInfoAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-image-picker', () => ({
|
||||||
|
requestCameraPermissionsAsync: jest.fn(),
|
||||||
|
requestMediaLibraryPermissionsAsync: jest.fn(),
|
||||||
|
launchCameraAsync: jest.fn(),
|
||||||
|
launchImageLibraryAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAvatarPicker', () => {
|
||||||
|
const originalPlatform = Platform.OS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
|
||||||
|
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
|
||||||
|
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
|
||||||
|
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: originalPlatform });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Android system picker without requesting broad media access', async () => {
|
||||||
|
const { result } = renderHook(() => useAvatarPicker());
|
||||||
|
|
||||||
|
act(() => result.current.showPicker());
|
||||||
|
const chooseFromLibrary = (Alert.alert as jest.Mock).mock.calls[0][2].find(
|
||||||
|
(option: { text: string }) => option.text === 'Choose from Library'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => chooseFromLibrary.onPress());
|
||||||
|
|
||||||
|
expect(ImagePicker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled();
|
||||||
|
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
exif: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the iOS library permission request before opening the picker', async () => {
|
||||||
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' });
|
||||||
|
(ImagePicker.requestMediaLibraryPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||||
|
const { result } = renderHook(() => useAvatarPicker());
|
||||||
|
|
||||||
|
act(() => result.current.showPicker());
|
||||||
|
const onSelect = (ActionSheetIOS.showActionSheetWithOptions as jest.Mock).mock.calls[0][1];
|
||||||
|
|
||||||
|
await act(async () => onSelect(2));
|
||||||
|
|
||||||
|
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import type { SentDare } from "@/features/dares/types";
|
|||||||
const incoming: SentDare = {
|
const incoming: SentDare = {
|
||||||
id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2,
|
id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2,
|
||||||
is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming",
|
is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming",
|
||||||
status: "pending", sender_notes: null, sent_at: "2026-08-28T10:00:00.000Z",
|
status: "pending", sender_notes: null, proof_type: "none", proof_media_id: null, sent_at: "2026-08-28T10:00:00.000Z",
|
||||||
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +55,23 @@ describe("useDares", () => {
|
|||||||
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" });
|
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches proof media to a submit when one is supplied", async () => {
|
||||||
|
mockLoad();
|
||||||
|
const postSpy = jest.spyOn(apiClient, "post").mockResolvedValue({ dare: { ...incoming, status: "submitted" } } as never);
|
||||||
|
const { result } = renderHook(() => useDares());
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submit("dare-1", "media-1");
|
||||||
|
});
|
||||||
|
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/submit", { proof_media_id: "media-1" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submit("dare-1");
|
||||||
|
});
|
||||||
|
expect(postSpy).toHaveBeenLastCalledWith("/v1/dares/dare-1/submit", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes a premium refusal to the paywall rather than the error banner", async () => {
|
it("routes a premium refusal to the paywall rather than the error banner", async () => {
|
||||||
mockLoad();
|
mockLoad();
|
||||||
jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402));
|
jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
import { renderHook, act } from '@testing-library/react-native';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
@@ -46,6 +46,7 @@ describe('useMediaSaver', () => {
|
|||||||
|
|
||||||
expect(result.current.saving).toBe(false);
|
expect(result.current.saving).toBe(false);
|
||||||
expect(typeof result.current.saveMedia).toBe('function');
|
expect(typeof result.current.saveMedia).toBe('function');
|
||||||
|
expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves local image successfully', async () => {
|
it('saves local image successfully', async () => {
|
||||||
@@ -180,8 +181,6 @@ describe('useMediaSaver', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sets saving to true during operation', async () => {
|
it('sets saving to true during operation', async () => {
|
||||||
let savingDuringOperation = false;
|
|
||||||
|
|
||||||
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
||||||
// This would capture the saving state during the async operation
|
// This would capture the saving state during the async operation
|
||||||
// but since we can't access result.current here synchronously,
|
// but since we can't access result.current here synchronously,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user