Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0a812f4
Merge pull request #119 from Coding-Moves/main
Muawiya-contact Sep 5, 2026
8ad2745
Fix APK native-gating: stop capturing the trailing period in runtimeV…
Muawiya-contact Sep 5, 2026
5c408b0
Merge pull request #120 from Coding-Moves/fix/release-apk-gating
Muawiya-contact Sep 5, 2026
cf4535c
Add /reset-password landing page for password recovery (#112)
Muawiya-contact Sep 5, 2026
42489e2
Add 'Forgot password?' to sign-in (#112)
Muawiya-contact Sep 5, 2026
28e9fbc
Harden /reset-password page (review of #122)
Muawiya-contact Sep 5, 2026
182b8cf
resetPassword: skip redirectTo when API base URL is unset (review of …
Muawiya-contact Sep 5, 2026
a45b81e
Merge pull request #122 from Coding-Moves/feat/forgot-password
Muawiya-contact Sep 5, 2026
cc14549
Fix jumping heart icon in History cards (#121)
Muawiya-contact Sep 5, 2026
252f368
Scale the whole app's type down ~10% from one lever
Muawiya-contact Sep 5, 2026
1a9ec48
Scale icon sizes with the text so proportions stay uniform (review of…
Muawiya-contact Sep 5, 2026
d86632a
Merge pull request #125 from Coding-Moves/fix/history-heart-alignment
Muawiya-contact Sep 5, 2026
2061058
Add branding links, developer attribution & feedback actions to About…
Muawiya-contact Sep 5, 2026
b4367c1
About: show a fallback alert when a link can't open (review of #126)
Muawiya-contact Sep 5, 2026
298b7bf
Merge pull request #126 from Coding-Moves/feat/about-branding
Muawiya-contact Sep 5, 2026
dabb488
Add GET /v1/concepts/{slug} to fetch a full concept (#124)
Muawiya-contact Sep 5, 2026
b95f313
Add a concept-detail modal reachable from any tab (#124)
Muawiya-contact Sep 5, 2026
14ee195
Make History & Saved cards open the concept detail; cap History at 10…
Muawiya-contact Sep 5, 2026
99e663b
Test the concept endpoint; dedup CONCEPTS_BY_ID; fix History nav type…
Muawiya-contact Sep 5, 2026
d3f5796
Merge pull request #127 from Coding-Moves/feat/concept-detail
Muawiya-contact Sep 5, 2026
21a9330
Bump version to 1.5.0 + What's New card
Muawiya-contact Sep 5, 2026
c48c00d
Merge pull request #128 from Coding-Moves/chore/release-1.5.0
Muawiya-contact Sep 5, 2026
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
8 changes: 6 additions & 2 deletions .github/workflows/release-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# Anchor the capture on a trailing digit so the period that ends the
# sentence ("runtimeVersion: 1.3.0. JS updates…") isn't swallowed into
# the version — "1.3.0." never equals "1.3.0", which used to make the
# gate think every JS-only release was native and rebuild needlessly.
prev=$(gh release view apk-latest --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null \
| sed -n 's/.*runtimeVersion: \([0-9][0-9.]*\).*/\1/p' | head -1)
| sed -n 's/.*runtimeVersion: \([0-9][0-9.]*[0-9]\).*/\1/p' | head -1)
echo "value=$prev" >> "$GITHUB_OUTPUT"

- name: Decide whether to rebuild
Expand Down Expand Up @@ -115,7 +119,7 @@ jobs:
run: |
set -euo pipefail
curl -fL -o one-concept.apk "${{ steps.build.outputs.url }}"
notes="Sideload build for the current native runtime. runtimeVersion: ${RV}. JS updates arrive over the air on release; reinstall from here only when a native release changes this."
notes="Sideload build for the current native runtime. runtimeVersion: ${RV} JS updates arrive over the air on release; reinstall from here only when a native release changes this."
if gh release view apk-latest --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload apk-latest one-concept.apk --repo "$GITHUB_REPOSITORY" --clobber
gh release edit apk-latest --repo "$GITHUB_REPOSITORY" --notes "$notes"
Expand Down
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ DIRECT_URL=postgresql://postgres.PROJECT:PASSWORD@aws-0-REGION.pooler.supabase.c

# Settings → API
SUPABASE_URL=https://PROJECT.supabase.co
# Public anon key (Settings → API). Safe to expose — it's already in the mobile
# bundle. Used by the /reset-password landing page to call Supabase auth.
SUPABASE_ANON_KEY=
# Full RLS bypass. Treat like a root password: never log it, never ship it.
SUPABASE_SERVICE_ROLE_KEY=
# Legacy HS256 secret. Unused when the project signs asymmetrically (ES256),
Expand Down
17 changes: 16 additions & 1 deletion backend/app/api/v1/concepts.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends, Response, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_db
from app.deps import CurrentUser, get_current_user
from app.schemas.daily import ConceptOut
from app.services.concepts import get_concept_out
from app.services.interactions import set_interaction

router = APIRouter(prefix="/concepts", tags=["concepts"])
Expand All @@ -12,6 +14,19 @@
_NO_CONTENT = Response(status_code=status.HTTP_204_NO_CONTENT)


@router.get("/{slug}", response_model=ConceptOut)
async def get_concept(
slug: str,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ConceptOut:
"""Full concept by slug, for reopening a History/Saved card's details."""
concept = await get_concept_out(db, user.id, slug)
if concept is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concept not found")
return concept


@router.put("/{slug}/like", status_code=status.HTTP_204_NO_CONTENT)
async def like(slug: str, user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db)) -> Response:
Expand Down
174 changes: 174 additions & 0 deletions backend/app/api/v1/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
loop with a clear instruction instead of a dead localhost tab.
"""

import json

from fastapi import APIRouter
from fastapi.responses import HTMLResponse

from app.config import get_settings

router = APIRouter(tags=["pages"])

_CONFIRMED = """<!doctype html>
Expand Down Expand Up @@ -38,3 +42,173 @@
@router.get("/confirmed", response_class=HTMLResponse, include_in_schema=False)
async def confirmed() -> str:
return _CONFIRMED


# The password-recovery link Supabase emails lands here (its Site URL / the
# redirectTo the app passes). Supabase verifies the token and appends the
# recovery session to the URL *fragment* (#access_token=...&type=recovery),
# which the browser never sends to us — so the token stays client-side. The
# page reads it and calls Supabase's auth REST endpoint directly to set the new
# password; the anon key it needs is public (already in the app bundle).
_RESET_PASSWORD = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One Concept — reset password</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #0f1115; color: #e8eaf0;
font: 16px/1.6 system-ui, sans-serif; }
main { width: 100%; max-width: 360px; padding: 2rem; box-sizing: border-box;
text-align: center; }
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
p { color: #9aa1b0; margin: 0 0 1.25rem; }
form { display: grid; gap: 0.75rem; text-align: left; }
label { font-size: 0.85rem; color: #9aa1b0; }
input { width: 100%; box-sizing: border-box; padding: 0.7rem 0.8rem;
border-radius: 10px; border: 1px solid #2a2f3a; background: #171a21;
color: #e8eaf0; font-size: 1rem; }
input:focus { outline: none; border-color: #6c8cff; }
button { margin-top: 0.5rem; padding: 0.8rem; border: 0; border-radius: 10px;
background: #6c8cff; color: #0f1115; font-size: 1rem; font-weight: 700;
cursor: pointer; }
button:disabled { opacity: 0.6; cursor: default; }
.msg { margin-top: 1rem; font-size: 0.9rem; }
.err { color: #ff8c8c; }
.ok { color: #7ee0a2; }
.hidden { display: none; }
</style>
</head>
<body>
<main>
<h1>Reset your password</h1>
<p id="intro">Choose a new password for your One Concept account.</p>

<form id="form">
<div>
<label for="pw">New password</label>
<input id="pw" type="password" autocomplete="new-password"
minlength="6" required>
</div>
<div>
<label for="pw2">Confirm new password</label>
<input id="pw2" type="password" autocomplete="new-password"
minlength="6" required>
</div>
<button id="submit" type="submit">Update password</button>
</form>

<p id="msg" class="msg"></p>
</main>

<script>
var CFG = __CONFIG__;
var form = document.getElementById('form');
var intro = document.getElementById('intro');
var msg = document.getElementById('msg');
var submit = document.getElementById('submit');

function fail(text) {
msg.textContent = text;
msg.className = 'msg err';
}
function disableForm() {
form.classList.add('hidden');
intro.classList.add('hidden');
}

// Recovery session arrives in the URL fragment (implicit flow). Supabase
// can report a failure in either the fragment or the query string, so
// check both and surface the real reason instead of the generic message.
var hashParams = new URLSearchParams(location.hash.slice(1));
var queryParams = new URLSearchParams(location.search.slice(1));
var accessToken = hashParams.get('access_token');
var type = hashParams.get('type');
var linkError = hashParams.get('error_description') || queryParams.get('error_description')
|| hashParams.get('error') || queryParams.get('error');

if (linkError) {
disableForm();
fail(linkError);
} else if (!accessToken || type !== 'recovery') {
disableForm();
fail('This reset link is invalid or has expired. Open the One Concept app and request a new link.');
}

form.addEventListener('submit', async function (e) {
e.preventDefault();
var pw = document.getElementById('pw').value;
var pw2 = document.getElementById('pw2').value;
if (pw.length < 6) { fail('Passwords need to be at least 6 characters.'); return; }
if (pw !== pw2) { fail('The two passwords do not match.'); return; }

submit.disabled = true;
msg.textContent = '';
msg.className = 'msg';
try {
var res = await fetch(CFG.url + '/auth/v1/user', {
method: 'PUT',
headers: {
'apikey': CFG.anonKey,
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ password: pw })
});
if (!res.ok) {
var body = await res.json().catch(function () { return {}; });
throw new Error(body.msg || body.error_description ||
'Could not reset your password. The link may have expired — request a new one from the app.');
}
// Drop the token from the URL/history now that it's spent.
history.replaceState(null, '', location.pathname);
disableForm();
msg.textContent = 'Password updated ✓ Open the One Concept app and sign in with your new password.';
msg.className = 'msg ok';
} catch (err) {
submit.disabled = false;
fail(err.message);
}
});
</script>
</body>
</html>"""


_RESET_UNCONFIGURED = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One Concept — reset password</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #0f1115; color: #e8eaf0;
font: 18px/1.6 system-ui, sans-serif; }
main { text-align: center; padding: 2rem; }
h1 { font-size: 1.6rem; margin-bottom: 0.5rem; }
p { color: #9aa1b0; }
</style>
</head>
<body>
<main>
<h1>Reset unavailable</h1>
<p>Password reset isn't configured on the server yet. Please try again
later.</p>
</main>
</body>
</html>"""


@router.get("/reset-password", response_class=HTMLResponse, include_in_schema=False)
async def reset_password() -> str:
settings = get_settings()
if not settings.supabase_anon_key:
# No public key configured — the page can't call Supabase, so fail
# clearly instead of rendering a form that silently can't submit.
return _RESET_UNCONFIGURED
config = json.dumps(
{"url": settings.supabase_url.rstrip("/"), "anonKey": settings.supabase_anon_key}
)
return _RESET_PASSWORD.replace("__CONFIG__", config)
4 changes: 4 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class Settings(BaseSettings):
# Present for legacy HS256 projects; this project signs with ES256 via JWKS.
supabase_jwt_secret: str | None = None
supabase_service_role_key: str | None = None
# Public anon key. Safe to expose — it's already shipped in the mobile
# bundle. The /reset-password landing page uses it (client-side) to call
# Supabase's auth REST endpoint; the page is inert without it.
supabase_anon_key: str | None = None

# Generation. The key lives here and only here — never in the app bundle.
gemini_api_key: str = ""
Expand Down
49 changes: 49 additions & 0 deletions backend/app/services/concepts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Read a single concept for the detail view.

History and Saved lists carry only a concept's name/topic, not its body, so the
app fetches the full concept (summary + example) by slug when a card is opened.
"""

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models import Concept, ConceptInteraction, Topic
from app.schemas.daily import ConceptOut


async def get_concept_out(db: AsyncSession, user_id, slug: str) -> ConceptOut | None:
"""The published concept with the given slug, or None if there isn't one.

like_count is other users' likes only — the client adds the viewer's own,
exactly as the daily and state endpoints do, so the number matches the card.
"""
like_count = (
select(func.count())
.select_from(ConceptInteraction)
.where(
ConceptInteraction.concept_id == Concept.id,
ConceptInteraction.liked_at.is_not(None),
ConceptInteraction.user_id != user_id,
)
.correlate(Concept)
.scalar_subquery()
)
stmt = (
select(Concept, Topic.slug, Topic.name, like_count)
.join(Topic, Topic.id == Concept.topic_id)
.where(Concept.slug == slug, Concept.status == "published")
)
row = (await db.execute(stmt)).first()
if row is None:
return None
concept, topic_slug, topic_name, likes = row
return ConceptOut(
id=concept.id,
slug=concept.slug,
title=concept.title,
summary=concept.summary,
example=concept.example,
topic_slug=topic_slug,
topic_name=topic_name,
like_count=likes,
)
31 changes: 31 additions & 0 deletions backend/tests/test_writes.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,49 @@
"""Write endpoints: completion, streaks, follows, likes, saves."""

import uuid
from datetime import timedelta

import pytest
from fastapi import HTTPException
from sqlalchemy import text

from app.services.concepts import get_concept_out
from app.services.interactions import complete_today, set_followed_topics, set_interaction
from app.services.selection import get_or_create_daily
from app.services.state import load_state
from app.services.streaks import compute_streaks
from tests.test_selection import DAY


async def _make_user(session) -> uuid.UUID:
"""A second bootstrapped user (the auth.users insert fires the profile trigger)."""
uid = uuid.uuid4()
await session.execute(
text("insert into auth.users (id, email) values (:id, :email)"),
{"id": uid, "email": f"{uid}@example.invalid"},
)
await session.commit()
return uid


async def test_get_concept_returns_body_and_only_others_likes(session, user):
other = await _make_user(session)
# Another user likes it, and so does the viewer — the returned count must
# exclude the viewer's own like (the client adds it back).
await set_interaction(session, other, "hash-tables", "liked_at", True)
await set_interaction(session, user, "hash-tables", "liked_at", True)

concept = await get_concept_out(session, user, "hash-tables")
assert concept is not None
assert concept.slug == "hash-tables"
assert concept.title and concept.summary and concept.topic_name
assert concept.like_count == 1, "only other users' likes, not the viewer's own"


async def test_get_concept_unknown_slug_returns_none(session, user):
assert await get_concept_out(session, user, "not-a-real-concept") is None


async def _assign_and_complete(session, user, day):
await get_or_create_daily(session, user, today=day)
await complete_today(session, user, day)
Expand Down
Loading