Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions backend/tools/verify_supabase_cutover_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

async def main() -> int:
engine = None
stage = "supabase_cutover_schema"
try:
engine = create_async_engine(
connection_url(os.environ.get("DIRECT_URL", "")),
Expand All @@ -29,7 +30,7 @@ async def main() -> int:
"set transaction isolation level repeatable read, read only"
))
await connection.execute(text("set local statement_timeout = '10s'"))
ready = await connection.scalar(text("""
schema_ready = await connection.scalar(text("""
select to_regclass('public.edge_worker_jobs') is not null
and to_regclass('public.edge_worker_runs') is not null
and to_regprocedure('public.edge_api_public_profile(text)') is not null
Expand All @@ -39,10 +40,23 @@ async def main() -> int:
and position('delivery_unknown' in coalesce(pg_get_functiondef(
to_regprocedure('public.edge_worker_claim_email(integer)')), '')) > 0
"""))
print("supabase_cutover_schema=" + ("ready" if ready else "missing"))
return 0 if ready else 1
stage = "profile_avatar_bucket"
bucket_ready = await connection.scalar(text("""
select exists (
select 1 from storage.buckets
where id = 'profile-avatars'
and public = false
and file_size_limit = 262144
and allowed_mime_types @> array[
'image/jpeg', 'image/png', 'image/webp'
]::text[]
)
"""))
print("supabase_cutover_schema=" + ("ready" if schema_ready else "missing"))
print("profile_avatar_bucket=" + ("ready" if bucket_ready else "missing"))
return 0 if schema_ready and bucket_ready else 1
except Exception as exc:
print("supabase_cutover_schema=error:" + type(exc).__name__)
print(stage + "=error:" + type(exc).__name__)
return 1
finally:
if engine is not None:
Expand Down
4 changes: 3 additions & 1 deletion docs/CODEBASE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ inside a root stack, with a concept-detail modal above them.
| `WeeklyQuizScreen.tsx` | Optional server-backed weekly quiz: eligibility progress, seven reviewed questions, icon-marked accessible answer selection, result feedback and reattempts. |
| `SubtopicQuizzesScreen.tsx` / `SubtopicQuizScreen.tsx` | Profile-linked optional quizzes for completed subtopics, frozen reviewed questions, icon-marked accessible answer selection, retries, and prior-score history. |
| `ProfileScreen.tsx` | Task-grouped account hub: profile/privacy, learning, topics, preferences, library and support. It keeps clear reminder prerequisites and immediate per-control queued reminder writes. |
| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Name and bio editing with temporary Android keyboard scroll room, camera/library photo preview and explicit save, account-bound picker recovery, bounded JPEG bytes from the picker or converter with a signature check, and confirmed avatar writes. |
| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Name and bio editing with temporary Android keyboard scroll room, camera/library photo preview and explicit save, account-bound picker recovery, bounded JPEG bytes from the picker or converter with a signature check, and confirmed avatar writes. `remoteProgressRepository.ts` gives the photo upload a 60-second request deadline and keeps photo-only 503 cooldowns off unrelated lesson requests; the screen labels a timeout separately from a connection failure. |
| `ProfileSharingScreen.tsx` / `ProfilePublishReviewSheet.tsx` / `PublicProfileLink.tsx` | Opt-in, independently selected sharing fields, a private review-before-publish step, explicit unpublish confirmation, native share/local QR, and uncached incoming public-profile view. |
| `PersonalizationScreen.tsx` | Server topic catalog and follow controls through `useTopics`. |
| `SavedScreen.tsx` | Recent/cached saved concepts, older metadata pagination, search/category filters, and detail navigation. |
Expand Down Expand Up @@ -376,6 +376,8 @@ The production Supabase API from #416 lives in `supabase/functions/api/` and
All 73 JSON paths have candidate handlers, including private avatar upload,
short-lived Storage signing, and editorial read/command routes. The avatar
image module bundles a pinned ImageMagick WASM file for 512px JPEG conversion.
Photo normalization and Storage upload failures produce distinct, data-free
incident log categories while the client receives a generic 503.
Its `public_pages.ts` serves the browser confirmation, recovery, and learner
privacy pages at the same API path.
Production clients use the Edge route. The historical parity checklist is in
Expand Down
5 changes: 4 additions & 1 deletion docs/PROFILE_AVATARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Learners may choose a built-in avatar, select a library photo, or take a camera

The Supabase Edge candidate for #416 retains the 25 MP JPEG limit and applies an 8 MP limit to PNG/WebP because those formats must be decoded at full size within the hosted memory limit. A larger PNG/WebP returns 422; the mobile client can ask for a smaller photo or JPEG during the #418 cutover.

On 2026-11-04, the production bucket was found missing even though the API and app were deployed. Creating the private bucket with the settings below restored gallery and camera photo saves on the owner's production Android phone. The production deploy guard now verifies these settings before publishing future Edge code.

The photo picker stores only the initiating account ID until it returns. Sign-out clears that marker; a result recovered after Android activity recreation appears as a preview only for the initiating account. The selected photo stays in memory until Save or Cancel, and an upload failure preserves the last confirmed avatar for retry.

Public-profile sharing remains private by default. The dedicated **Profile avatar** choice must be reviewed and published before a visitor receives a short-lived delivery URL. Turning that choice off removes it from future public responses immediately.
Expand All @@ -17,7 +19,8 @@ Public-profile sharing remains private by default. The dedicated **Profile avata
Before merging and deploying the feature:

1. Apply migration `0040_profile_avatar_bio.sql` through the normal migration process. Do not add it to `backend/migrations/applied.txt` until production confirms it.
2. In Supabase Storage, create `profile-avatars` as a **private** bucket with a 256 KiB file limit and JPEG/PNG/WebP allowlist. Storage buckets are managed by Supabase Storage, not the application PostgreSQL migration runner.
2. In Supabase Storage, create `profile-avatars` as a **private** bucket with a 256 KiB file limit and JPEG/PNG/WebP allowlist. Use the [Storage dashboard or API](https://supabase.com/docs/guides/storage/buckets/creating-buckets); the application PostgreSQL migration runner does not create buckets.
The protected production deployment check now reads the bucket metadata and reports `profile_avatar_bucket=missing` before deploying API code if this bucket is absent or misconfigured.
3. Confirm the production Supabase Edge API has its server-only Storage credential; it must never be copied to Expo variables.
4. Build and install a new Android/iOS binary. `expo-image-picker` and its camera/photo permission messages are native configuration, so an OTA alone cannot enable photo selection on existing installs.
5. On a staging account, test each preset, denied library/camera permission, picker cancellation, preview Save/Cancel, an oversized/invalid image, photo replacement/removal, sign-out during picker or upload, and public-avatar sharing on/off from another browser. Check the Android activity-recreation path on a physical device; web tests do not prove native picker behavior.
8 changes: 8 additions & 0 deletions docs/WORK_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ claims as completed work.

## Current status

### #457 production photo upload follow-up — 2026-11-04

- The owner confirmed v1.10.18 still cannot save a profile photo on Android. Two November 2 screenshots show a completed photo preview followed by either “Could not reach the photo service” (no HTTP response) or “Photo saving is unavailable right now” (HTTP 5xx). These no longer show the earlier local photo-preparation error. Issue #457 was reopened; do not close it until a real production device save succeeds.
- This `develop` follow-up uses separate changes for a 60-second photo-only request deadline and safe Edge incident logs that distinguish image normalization from Storage upload failures. The default 15-second deadline for other requests remains unchanged. No SQL migration, production deploy, or Cron change is involved.
- A production read-only query returned **no row** for `storage.buckets.id='profile-avatars'`. This was the confirmed configuration blocker: a normalized JPEG had nowhere to be stored. The owner created the bucket, then confirmed **both gallery and camera saves work** on the installed production phone and an uploaded image appears in Supabase Storage. A second read-only query confirmed the bucket is private, has a 262144-byte limit, and allows exactly JPEG, PNG, and WebP. The supplied earlier function-log excerpt showed normal profile GET/PATCH traffic but no photo PUT; the separate no-response symptom did not recur in the successful retest. The protected schema guard now detects a missing or misconfigured bucket before future Edge deployments.
- Focused commits: `39bca53` extends only the photo upload deadline; `dee5839` adds data-free normalization/Storage incident categories and 503 regressions; `b5227ae` adds the read-only production bucket gate; `1b33baa` distinguishes timeout from offline in the mobile error message; `881cd86` prevents an avatar Storage 503 from cooling down unrelated lesson requests. Validation passed: mobile TypeScript, focused mobile client/timeout tests, Edge TypeScript and avatar handler tests, Python syntax, whitespace, and the owner's production gallery/camera checks after bucket creation. [PR #482](https://github.com/Coding-Moves/one-concept/pull/482) is open for review; the new code is not deployed.
- Re-review found that the first timeout implementation also changed default 15-second Auth and profile-sharing failures. `e4ce9df` keeps the original AbortError for those callers and classifies timeouts only for the explicitly extended photo request; focused timeout and API-client tests and mobile TypeScript passed. `e8feb3a` reports `profile_avatar_bucket=missing` separately from schema failures in the protected read-only deployment check; Python syntax and whitespace passed. All four hosted checks passed on this code in [run 41871285348](https://github.com/Coding-Moves/one-concept/actions/runs/41871285348).

### v1.10.18 photo release preparation — 2026-11-02

- The owner has one phone and asked to test the remaining photo fix on the production app. PR #477 merged into `develop` as `2924c81`, its preview OTA passed, and all four PR quality checks passed; the owner has not yet verified the native photo path on a device. [Preparation PR #478](https://github.com/Coding-Moves/one-concept/pull/478) targets `develop` for a JS-only v1.10.18 release with production phone verification after rollout.
Expand Down
14 changes: 10 additions & 4 deletions mobile/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* module never imports the auth stack (and never stores a token itself).
*/

import { fetchWithTimeout } from './fetchWithTimeout';
import { fetchWithTimeout, RequestTimeoutError } from './fetchWithTimeout';
import { ApiConfigurationError, ApiError } from './errors';

export { ApiConfigurationError, ApiError } from './errors';
Expand Down Expand Up @@ -79,8 +79,10 @@ interface RequestOptions {
/** Raw body for a narrow authenticated upload; never queued or persisted. */
rawBody?: BodyInit;
contentType?: string;
/** Give bounded uploads time for the first Edge image worker to start. */
timeoutMs?: number;
signal?: AbortSignal;
/** A domain-specific invitation cooldown must not pause daily learning. */
/** A request-local failure must not pause daily learning. */
rateLimitScope?: 'request';
}

Expand Down Expand Up @@ -111,9 +113,13 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
headers,
body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)),
signal: options.signal,
});
}, options.timeoutMs);
} catch (cause) {
if (epoch !== accountEpoch) throw new ApiError(401, 'Account changed');
if (cause instanceof RequestTimeoutError) {
// A stalled request does not prove that the device lost connectivity.
throw new ApiError(0, 'Request timed out', 'timeout');
}
// Offline or unreachable host: callers fall back to cached state.
setConnectivity(false);
throw new ApiError(0, 'Network request failed', cause);
Expand All @@ -135,7 +141,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
? (payload as { detail: unknown }).detail
: null;
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
if ((response.status === 429 && options.rateLimitScope !== 'request') || response.status === 503) {
if ((response.status === 429 || response.status === 503) && options.rateLimitScope !== 'request') {
retryUntil = Math.max(retryUntil, Date.now() + retryAfterMs);
}
throw new ApiError(response.status, `Request failed: ${response.status}`, detail, retryAfterMs);
Expand Down
21 changes: 19 additions & 2 deletions mobile/src/api/fetchWithTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
/** Bound stalled connections, so screens can offer retry instead of spinning forever. */
export const fetchWithTimeout: typeof fetch = async (input, init) => {
export class RequestTimeoutError extends Error {
constructor() {
super('Request timed out');
this.name = 'RequestTimeoutError';
}
}

export const fetchWithTimeout = async (
input: RequestInfo | URL,
init?: Parameters<typeof fetch>[1],
timeoutMs?: number,
): Promise<Response> => {
const controller = new AbortController();
let timedOut = false;
const signal = init?.signal;
const abort = () => controller.abort();
if (signal?.aborted) abort();
signal?.addEventListener('abort', abort);
const timeout = setTimeout(abort, 15000);
const timeout = setTimeout(() => { timedOut = true; abort(); }, timeoutMs ?? 15000);
try {
return await fetch(input, { ...init, signal: controller.signal });
} catch (error) {
// Only explicitly extended requests opt into the new timeout error.
// Supabase Auth and other existing callers keep their prior AbortError.
if (timedOut && timeoutMs !== undefined) throw new RequestTimeoutError();
throw error;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
Expand Down
1 change: 1 addition & 0 deletions mobile/src/screens/EditProfileScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function photoSaveError(error: unknown): string {
if (error instanceof ProfilePhotoPreparationError) return error.message;
if (error instanceof ApiConfigurationError) return 'Install the latest app update before saving a photo.';
if (error instanceof ApiError) {
if (error.status === 0 && error.detail === 'timeout') return 'Photo saving took too long. Please try again.';
if (error.status === 0) return 'Could not reach the photo service. Check your connection and try again.';
if (error.status === 400 || error.status === 422) return 'This photo could not be processed. Choose another image.';
if (error.status === 401 || error.status === 403) return 'Your session needs attention. Sign in again, then retry your photo.';
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/services/remoteProgressRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export class RemoteProgressRepository implements ProgressRepository {

async uploadAvatar(jpeg: Uint8Array<ArrayBuffer>, userId: string): Promise<ProgressState> {
const epoch = this.epoch;
const payload = await this.request<StatePayload>(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: jpeg, contentType: 'image/jpeg', expectedUserId: userId });
const payload = await this.request<StatePayload>(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: jpeg, contentType: 'image/jpeg', expectedUserId: userId, timeoutMs: 60_000, rateLimitScope: 'request' });
return this.fromState(payload, epoch);
}

Expand Down
36 changes: 36 additions & 0 deletions mobile/tests/apiClient.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ test('late old-account responses cannot alter new-account connectivity or return
assert.equal(getConnectivity(), false);
});

test('a photo request timeout stays distinct from an offline transport failure', async t => {
invalidateAccountRequests(); setConnectivity(true); setTokenProvider(async () => 'token');
t.mock.timers.enable({ apis: ['setTimeout'] });
t.mock.method(globalThis, 'fetch', (_, { signal }) => new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}));
const pending = apiRequest('/v1/me/avatar', {
method: 'PUT', rawBody: new Uint8Array([0xff, 0xd8, 0xff]),
contentType: 'image/jpeg', timeoutMs: 60_000,
});
const rejected = assert.rejects(pending, error =>
error instanceof ApiError && error.status === 0 && error.detail === 'timeout');
await tick();
t.mock.timers.tick(60_000);
await rejected;
assert.equal(getConnectivity(), true);
});

test('a photo Storage failure does not pause unrelated lesson requests', async t => {
invalidateAccountRequests(); setTokenProvider(async () => 'token');
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return calls === 1
? new Response('{"detail":"unavailable"}', { status: 503, headers: { 'Retry-After': '30' } })
: new Response('{"daily":true}', { status: 200 });
});
await assert.rejects(apiRequest('/v1/me/avatar', {
method: 'PUT', rawBody: new Uint8Array([0xff, 0xd8, 0xff]),
contentType: 'image/jpeg', rateLimitScope: 'request',
}), error => error instanceof ApiError && error.status === 503);
assert.equal(apiRetryDelay(), 0);
assert.deepEqual(await apiRequest('/v1/daily'), { daily: true });
assert.equal(calls, 2);
});

test('429 Retry-After prevents subsequent requests until the server deadline', async t => {
invalidateAccountRequests(); setTokenProvider(async () => 'token');
let now = 100000;
Expand Down
18 changes: 17 additions & 1 deletion mobile/tests/fetchWithTimeout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ test('stalled fetch aborts after 15 seconds', async (t) => {
await rejected;
});

test('photo-sized requests may use a longer deadline without changing the default', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
t.mock.method(globalThis, 'fetch', (_, init) => new Promise((_, reject) => {
signal = init.signal;
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}));
const pending = fetchWithTimeout('https://example.invalid/photo', { method: 'PUT' }, 60_000);
const rejected = assert.rejects(pending, { name: 'RequestTimeoutError' });
t.mock.timers.tick(15_000);
assert.equal(signal.aborted, false);
t.mock.timers.tick(45_000);
await rejected;
});

test('forwards caller cancellation and successful responses', async (t) => {
const response = new Response('{}');
const fetch = t.mock.method(globalThis, 'fetch', async (_, { signal }) => {
Expand All @@ -23,5 +38,6 @@ test('forwards caller cancellation and successful responses', async (t) => {
const controller = new AbortController();
controller.abort();
await assert.rejects(fetchWithTimeout('https://example.invalid', { signal: controller.signal }), { name: 'AbortError' });
assert.equal(fetch.mock.callCount(), 2);
await assert.rejects(fetchWithTimeout('https://example.invalid', { signal: controller.signal }, 60_000), { name: 'AbortError' });
assert.equal(fetch.mock.callCount(), 3);
});
Loading
Loading