From Flask Monolith to Multiplatform App: Migration Journey Part 5
Follow-up to
Journey Part 4.
Part 4 closed with
"Next up: eas submit --platform ios for a real TestFlight build." This is
what happened between "it runs on my iPhone" and "it is in App Review":
a real logo, legal pages, store metadata, the moderation features Apple
insists on for apps with user-generated content, one privacy bug, and a
first App Review round trip.
Table of Contents
- What we're building
- Tech stack additions
- A real logo, and the asset pipeline behind it
- Why the new icon didn't show up
- Legal pages: privacy, support, terms
- Putting the logo into the UI
- The App Store Connect listing
- EAS submit,
ascAppIdand version numbers - Demo data and store screenshots
- Guideline 1.2, backend: blocking and reporting
- Guideline 1.2, clients: terms checkbox and report/block menu
- The email leak
- Supabase custom SMTP with Proton
- App Review round one: "Information Needed"
- Bugs that bit us
- Files changed
1. What we're building
Part 4 ended with an ad-hoc build on one phone. Going public is a different job: the app needs a logo that is not a placeholder, public URLs for a privacy policy and support, store text, screenshots, a build signed for the App Store, and — because users can see each other's workouts and send messages — a way to report content and block users. Apple checks all of that by hand.
None of this is glamorous, and almost none of it is code. But a lot of it turns out to be engineering: image processing with real constraints, privacy law translated into page text, and a block feature that has to be enforced on every endpoint that returns another user's data.
Learn more: App Review Guidelines
2. Tech stack additions
| Technology | Role | Learn more |
|---|---|---|
ImageMagick (magick) | Derive every icon size from one source image | ImageMagick |
xcrun simctl | Set a clean status bar and take simulator screenshots | simctl |
next/image | Logo above titles, no layout shift | next/image |
@headlessui/react Menu + Dialog | The "..." report/block menu on web | Headless UI |
expo-web-browser | Opens privacy/terms/support in an in-app browser | expo-web-browser |
UserBlock, Report models | Blocking and reporting, backend side | SQLAlchemy ORM |
Alembic revision d4a8e2f1b9c3 | Adds both tables and user.terms_accepted_at | Alembic |
Flask CLI group reports | Review and resolve reports from the shell | Flask CLI |
| Supabase custom SMTP + Proton | Auth emails from our own domain | Supabase SMTP |
3. A real logo, and the asset pipeline behind it
Part 4 shipped a generated placeholder (a white circle-arrow on blue). The real logo arrived as one 1024×1024 image: a white athlete inside a ring with a progression arrow, on a blue rounded square with a soft shadow.
One source image has to become many files, each with different rules:
| Target | Rule |
|---|---|
| iOS app icon | 1024², no alpha, no rounded corners — Apple masks it |
| Android adaptive icon | foreground on transparent, background a flat colour, glyph inside the central safe zone |
| Splash | glyph only, on the splash background colour |
Web favicon.ico | 16/32/48 px in one file |
| Web PWA/apple icons | 192, 512 and 180 px |
The source image is the problem: it has rounded corners and a shadow baked in. Using it directly for iOS would show white or grey corners, because Apple rounds the icon itself.
Extracting the glyph
The trick is to treat the red channel as an alpha mask. The background is
blue (red ≈ 30), the glyph is white (red = 255), so red is a clean
brightness map. The glyph's bounding box was found once with -trim:
# 648x592 glyph at offset +200+234; red channel becomes the alpha mask
magick logo.png -crop 648x592+200+234 +repage \
-channel R -separate +channel -level 34%,90% alpha.png
magick -size 648x592 xc:white alpha.png \
-compose CopyOpacity -composite glyph.png
-level 34%,90% is the important part. The first attempt used
14%,88% and left a faint blue-grey halo of the old shadow around the white
shape, visible only when the icon was previewed next to a flat colour.
Raising the black point cut the halo; the preview strip made it obvious.
From glyph.png every target is a composite on the brand blue #208AEF:
# iOS icon: 700px glyph, flat blue, alpha removed
magick -size 1024x1024 xc:"#208AEF" \( glyph.png -resize 700x700 \) \
-gravity center -compose over -composite -alpha off icon.png
The Android foreground uses 560 px (about 55 % of the canvas, inside the
adaptive-icon safe zone) on a transparent canvas; the monochrome variant is
the same file. The favicon and web icons get a rounded-rectangle mask
(roundrectangle with a corner radius of 22 %) so a browser tab looks like
the app icon, while apple-icon.png stays square, like the iOS icon.
Pattern name: derive, don't redraw. One source of truth plus a script per target means a logo change is a rerun, not a design session. Lesson learned: always look at the result. The first conversion looked fine in a file listing; only a side-by-side preview on three backgrounds exposed the halo. (Part 4 learned the same thing with
rsvg-convert.) Learn more: Apple — App icons, Android — adaptive icons
4. Why the new icon didn't show up
After replacing mobile/assets/images/icon.png, npm run ios still showed
the old icon. Part 4 already documented the cause for a permission; it
applies to icons too.
mobile/ios is generated (and git-ignored). expo run:ios only runs the
native prebuild — which copies icon.png into the Xcode asset catalog —
when ios/ does not exist yet. After that it just rebuilds the old native
project. The proof was one command:
magick compare -metric RMSE assets/images/icon.png \
ios/CalisthenicsProgression/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png null:
A non-zero difference means the asset catalog is stale. The fix is to regenerate the native project:
npx expo prebuild -p ios --no-install
The remaining difference after prebuild (about 0.0002 normalized RMSE) is just re-encoding. EAS cloud builds don't have this problem, because they prebuild from scratch every time.
Lesson learned: anything in
app.jsonorassets/that ends up inside the native project (icons, splash, permissions) needs a prebuild to reach a local native build. If a change "doesn't take", suspect the generatedios/folder before suspecting your code.
5. Legal pages: privacy, support, terms
Apple requires a public privacy policy URL and support URL, and for
user-generated content, terms users agree to. They live in the Next.js app
as three plain pages (/privacy, /support, /terms) sharing one
LegalPage layout component. The middleware (web/src/proxy.ts) has a
PUBLIC_ROUTES list, and all three had to be added, or an anonymous visitor
— including App Review — would be redirected to the login page.
Swiss law first, GDPR second
The primary law is the Swiss Federal Act on Data Protection (DSG, in force since 1 September 2023). The differences matter:
- The DSG has no list of "legal bases" like the GDPR. Processing is allowed as long as the principles of Art. 6 are respected (lawfulness, good faith, proportionality, purpose limitation, transparency).
- The duty to inform is Art. 19: identity and contact of the controller, purpose, recipients, and the countries involved when data leaves Switzerland.
- Data subject rights are Art. 25 (access), 28 (portability) and 32 (rectification, deletion). The authority is the EDÖB.
- Disclosure abroad (Art. 16) is fine for countries with adequate protection, which includes the EU.
The GDPR still applies because the app is offered in the EU App Store, so
the policy has a separate section for EU/EEA users with the Art. 6(1)(b)
and (f) GDPR bases. Both laws live side by side; neither pretends to be
the other.
Concrete decisions that fell out of this:
- Hosting is named: Hetzner (Germany), Supabase (data in Ireland, but Supabase Inc. is a US company, so access from the US cannot be excluded), and Proton (Switzerland) for email.
- No postal address. Art. 19 requires identity and contact details, not a street address. The page names the controller and gives an email. (For Apple, registering as a non-trader keeps the address off the store page.)
- Retention is a promise, so it must be true. The policy says server
logs are deleted after 14 days. That meant setting
MaxRetentionSec=14dayinjournald.confon the VPS and checking/etc/logrotate.d/nginx. Text that nobody enforces is worse than silence.
Lesson learned: copy-pasting a legal template is the same trap as matching i18n keys by string similarity (Part 4): it looks right and is wrong in the details. Cite the law that applies to you.
Hiding the contact address from scrapers
The landing page already built its contact address in the browser only
(the CONTACT_EMAIL_USER / CONTACT_EMAIL_DOMAIN split in
lib/contact.ts). The legal pages
had the address as a literal string. A small client component reuses the
trick:
"use client";
export function ContactEmail() {
const mounted = useMounted();
if (!mounted) return <span className="text-gray-400">…</span>;
const address =
`${CONTACT_EMAIL_USER}${String.fromCharCode(64)}${CONTACT_EMAIL_DOMAIN}`;
return <a href={`mailto:${address}`}>{address}</a>;
}
useMounted() is built on useSyncExternalStore: it returns false during
server rendering and hydration, true afterwards. Verification was
mechanical — curl localhost:3000/privacy | grep -c sixt.services returns
0, while the browser shows the link.
Pattern name: client-only rendering of a sensitive value. It stops naive scrapers, not a crawler that runs JavaScript.
Terms of use (the Guideline 1.2 part)
/terms states the rules (zero tolerance for harassment, sexual content,
illegal content, spam), how to report and block, that reports are reviewed
within 24 hours, and what happens on violations. It is only useful if users
actually agree to it, which is a checkbox at registration — see
section 11.
Learn more: Swiss FADP (DSG), GDPR text, Apple — App privacy details
6. Putting the logo into the UI
Replacing files is not enough if the UI never loads them. The web navbar
had a LogoMark component: a hand-written SVG (the old circle-arrow),
completely independent of the icon files. It now renders /icon-192.png
through next/image at 24 px.
The login pages and the landing page got the logo above the title, "as wide as the title". That is a layout question, and the answer is a CSS trick worth knowing:
export function BrandLogo({ children }: { children: React.ReactNode }) {
return (
<div className="inline-block">
<Image src="/icon-512.png" alt="" width={512} height={512} priority
className="mb-4 h-auto w-0 min-w-full" />
{children}
</div>
);
}
inline-blockshrinks the wrapper to its widest child.w-0makes the image contribute no intrinsic width, so the title text alone decides the width.min-w-fullthen stretches the image to that width, andh-autokeeps it square.
On the mobile login screen the logo is a plain Image (120 × 120, rounded
corners), since there is no title text to match.
Pattern name: let one child size the container. Take the image out of the width calculation (
w-0), then stretch it back (min-w-full).
7. The App Store Connect listing
Most of App Store Connect is text with limits: subtitle 30 characters,
promotional text 170, keywords 100, description 4000. Everything is kept in
store-assets/listing.md, in English and
German, so a later release starts from the last text.
Rules that shaped the copy:
- Keywords: don't repeat words from the app title (they are indexed already), separate with commas, no spaces.
- Description: describe only features that exist, no prices, no other platforms.
- Routing App Coverage File: only for navigation apps — leave empty.
- Where the privacy URL goes: not on the version page, but under App Information (and in App Privacy).
Privacy "nutrition labels": storing is collecting
App Store Connect shows "Data is not collected from this app" by default. It looks plausible for a hobby app with no analytics. It is wrong. Apple's definition of collect is transmitting data off the device so that the developer can access it for longer than needed to serve the request in real time. Our backend stores the account, workouts and messages until the user deletes them. That is collecting, regardless of purpose.
The honest declaration, all linked to the user, not used for tracking, purpose app functionality: email address, name (username), photos (profile and exercise images), other user content (workouts, exercises, "about me"), emails or text messages (direct messages), user ID, and fitness data. It matches section 2 of the privacy policy on purpose; the two must never disagree.
Lesson learned: "no ads, no tracking" is a statement about purpose. The privacy label asks about storage. They are different questions.
8. EAS submit, ascAppId and version numbers
eas submit needs to know which App Store Connect app to upload to. That
is the ascAppId: the numeric "Apple ID" of the app (App Information →
General Information). It goes in eas.json:
"submit": {
"production": { "ios": { "ascAppId": "<numeric id>" } }
}
It is not a secret, so it is fine to commit.
The flow:
cd mobile
eas build -p ios --profile production
eas submit -p ios --latest
Two version numbers, two owners
| Number | Source | Who bumps it |
|---|
| Version (CFBundleShortVersionString, e.g. 1.0.0) | expo.version in app.json | you, per store release |
| Build number | EAS remote (appVersionSource: "remote") | EAS, via autoIncrement: true |
That is why TestFlight kept showing version 1.0.0 with a rising build
number — the build number climbing is correct. Only bump the version for
a new store release; TestFlight builds of the same version only need a
new build number.
eas build:version:get -p ios shows where the counter is.
Learn more: EAS Submit, App version management
9. Demo data and store screenshots
Store screenshots of an empty app are worthless, and screenshots of your
real data leak it. A dedicated seed script,
backend/scripts/seed_demo.py, fills one user's account with eight weeks of
plausible training:
python scripts/seed_demo.py --email me@example.com # add
python scripts/seed_demo.py --email me@example.com --reset # remove
Design choices:
- Only touches the named user, and recognises its own rows by title
(
DEMO_WORKOUTS,DEMO_EXERCISES), so--resetnever deletes real data. - Refuses non-SQLite databases unless
--force— a safety rail against seeding production by accident. - Reps grow with the week (
base + step * progress), so charts show progression, not noise.
Two things went wrong the first time:
User.queryraisedAttributeError: this project's models use a plain SQLAlchemy 2.0Base, not Flask-SQLAlchemy'sdb.Model, so the legacy.queryaccessor doesn't exist. The script usesdb.session.scalar(select(...))instead.- The statistics chart fell to zero in the current week. The seed created
past weeks and upcoming workouts, but nothing done this week, so the
weekly bucket was empty. The loop now includes the current week and skips
days that haven't happened yet (
if day >= today: continue).
Making the simulator look like a store screenshot
xcrun simctl status_bar booted override --time 9:41 \
--batteryState charged --batteryLevel 100 \
--cellularMode active --cellularBars 4 --wifiBars 3
xcrun simctl io booted screenshot raw/01-workouts.png
xcrun simctl status_bar booted clear # afterwards!
The iPhone 17 produces 1206 × 2622; the store wants 1320 × 2868 (6.9") and
1284 × 2778 (6.5"). Scale with -resize WxH^, anchor to the bottom, and crop
with -extent, so nothing is distorted:
magick raw/x.png -resize 1320x2868^ -gravity south \
-extent 1320x2868 -alpha off out.png
Four screens made the cut. Two were dropped deliberately: Explore (full of "Test …" rows from the dev database) and Profile (it prints the real email address).
Lesson learned: check screenshots for leaked data, not just layout. Anything on screen is published to everyone.
10. Guideline 1.2, backend: blocking and reporting
Guideline 1.2 requires, for apps with user-generated content: terms users accept, a way to report content, a way to block users, and published contact details. The backend part is two tables and a lot of filtering.
The models
class UserBlock(Base):
__tablename__ = "user_block"
blocker_id = db.Column(db.Integer, db.ForeignKey("user.id"), ...)
blocked_id = db.Column(db.Integer, db.ForeignKey("user.id"), ...)
__table_args__ = (
db.UniqueConstraint("blocker_id", "blocked_id", name="uq_user_block_pair"),
)
class Report(Base):
reporter_id, reported_user_id # both FK -> user.id
target_type # 'user' | 'workout' | 'exercise' | 'message'
target_id, reason, details, status # status: 'open' | 'resolved'
User also got terms_accepted_at, a nullable timestamp.
One block, enforced everywhere
A block is only real if every endpoint that returns another user's data
respects it. Two helpers in models.py do the work:
def blocked_user_ids(user_id) -> set[int]: ... # either direction
def is_blocked_between(user_id, other_id) -> bool: ...
"Either direction" is the key. If A blocks B, neither should see the other: no profile, no workouts, no exercises, no messages, no follows. That is symmetric on purpose, so the blocked user can't keep watching you.
The cheapest enforcement point already existed: is_visible_to(owner_id, visibility, viewer), the single place that decides whether content is
visible. One added guard covers every exercise and workout lookup that goes
through it:
if viewer is not None and is_blocked_between(viewer.id, owner_id):
return False
List endpoints can't call a per-object check, so they filter in SQL:
| Endpoint | Block handling |
|---|---|
GET /explore | Workout.user_id.not_in(blocked_ids) |
GET /users/<u> | 404 if blocked either way |
POST /users/<u>/follow | 404 if blocked |
| followers / following / follow-requests | rows filtered; 404 for a blocked target |
GET /messages | Message.sender_id.not_in(blocked_ids) |
POST /messages/<u> | 404 if blocked |
GET /exercises?user=all | ExerciseDefinition.user_id.not_in(...) |
Blocked profiles return 404, not 403. A 403 would confirm "this user exists and has blocked you"; a 404 reveals nothing.
Creating a block also deletes the Follow rows in both directions (and
refreshes the follow-request counters), the same cleanup pattern account
deletion already used.
Pattern name: enforce at the chokepoint. Put the rule where decisions are already centralised (
is_visible_to), and filter explicitly only where a list query bypasses it.
Reports
POST /reports validates the target type, the reason (spam, harassment,
inappropriate, illegal, other) and a 500-character details limit,
then resolves the reported user itself from the target. A client never
tells the server whom it is reporting; the server looks it up:
- a workout or exercise must be visible to the reporter (otherwise you could probe for private content);
- a message must have been received by the reporter;
- reporting your own content is a 400;
- the same open report from the same reporter is idempotent (200, not a duplicate row).
Each new report sends an email to every admin (User.admin=True), reusing
the send_email + render_template pattern from the "new user" mail. Two
CLI commands close the loop:
flask reports list [--all]
flask reports resolve <id>
Account deletion must learn the new tables
DELETE /auth/account deletes rows by hand in dependency order. New tables
with foreign keys to user.id have to join that list (blocks in either
role, reports as reporter or reported), or deletion breaks on PostgreSQL —
SQLite in tests enforces foreign keys too, via a pragma, so a test catches it.
The migration, tested against real PostgreSQL
The project's standing rule: SQLite forgives what PostgreSQL punishes.
The revision d4a8e2f1b9c3 (down revision c1c659752107) only uses
strings, integers and timestamps, with server_default='open' for the status text — no
boolean literals, the classic trap. Still, it was run against a throwaway
database inside the local Supabase Postgres container, then dropped.
Two surprises on the way:
- The old migrations don't build an empty PostgreSQL database from scratch
(
relation "exercises" does not exist), because production was created differently. The test therefore built the previous schema withdb.create_all(), dropped the new tables, ranflask db stamp c1c659752107, and then testedupgrade→downgrade→upgrade. flask db downgrade -1is not valid; Flask-Migrate takes a revision id.
Terms acceptance on the server
The registration form sends terms_accepted_at inside Supabase's
user_metadata. When the Flask user is auto-created on first API hit
(_get_or_create_user), the server only checks that the key is present and
stores its own clock (datetime.now(timezone.utc)), not the client's
timestamp. A client can lie about a time but not about when its request
arrived. POST /auth/accept-terms exists for a later re-consent flow.
Lesson learned: never store a client-supplied timestamp as evidence of anything. Use the client's value as a signal, your own clock as the record. Learn more: Apple — Guideline 1.2
11. Guideline 1.2, clients: terms checkbox and report/block menu
Terms at registration
Web: a required checkbox with a link to /terms, plus a guard in
handleSubmit (the HTML required attribute alone is not trusted). Mobile
uses a Switch and an onPress link that opens /terms through
openBrowserAsync, since React Native has no checkbox element. Both pass
options.data.terms_accepted_at to supabase.auth.signUp.
One menu, four targets
A single component per platform, ReportBlockMenu, takes the author's
username, a targetType (user, workout, exercise, message) and a
targetId:
- Web: a headlessui
Menu("..." button) with two items, aDialogfor the report reason and details, and the existingConfirmDialogfor blocking. - Mobile:
Alert.alertfor the action list and the block confirmation (the platform idiom, like delete confirmations in Part 4), and apageSheetModalfor the reason picker.
It renders null for the viewer's own content (compared via the profile
query), and takes leaveOnBlock so blocking from a profile or exercise page
navigates away from content that just disappeared.
It is mounted in five places: the Explore workout card, the user profile header, message cards, exercise cards, and the exercise detail page (on both platforms).
Hooks (use-moderation.ts, nearly identical on web and mobile) keep to the
house style: useBlockedUsers, useBlockUser, useUnblockUser,
useReport. After a block change they invalidate nine query keys
(blocks, user, explore, followers, following, follow-requests,
messages, exercises, notifications), because a block changes what
many screens may show.
Blocked users are listed (with Unblock) in the web profile page and in a
new mobile screen, blocked-users.
Tests
The backend suite grew by 29 tests (test_api_moderation.py, plus two for
email privacy): block/unblock and idempotency, follows removed both ways,
every filtered endpoint in both directions (via
@pytest.mark.parametrize), report validation and resolving, the admin
email, and account deletion with blocks and reports present.
The mobile test (ReportBlockMenu.test.tsx) needed care:
- RNTL v14's
fireEvent.pressis async and must be awaited; unawaited calls produced "overlapping act() calls" and one run that hung the whole worker. - The callbacks passed to
Alert.alertrun outside React's test environment, so calling them needsawait act(async () => …), otherwise the state update never renders.
Lesson learned: when a test hangs instead of failing, suspect an un-awaited async API first (here,
fireEvent), not the component.
Expo Router's typed routes are generated
router.push("/blocked-users") failed tsc with TS2345 even though the
file existed. The typed-routes file (.expo/types/router.d.ts) is
generated while Metro runs. Starting the dev server regenerated it, and
the error disappeared. If a new route "doesn't exist" for TypeScript, start
Metro once.
12. The email leak
User.to_dict() included email, and GET /users/<username> returned it
to any signed-in user. No client displayed another user's address, so
nothing looked wrong — but the API handed it out. It was noticed while
reading the code to plan the moderation work, not by a test or a report.
The fix is "private by default":
def to_dict(self, include_email: bool = False) -> dict[str, Any]:
data = {"id": self.id, "username": self.username, ...}
if include_email:
data["email"] = self.email
return data
Only the three own-profile endpoints (GET /auth/profile, profile update,
profile picture update) pass include_email=True. Two tests pin the
behaviour: another user's profile must not contain the address (checked
against the stringified response, so a renamed key can't slip through),
and your own profile must.
Pattern name: opt-in for sensitive fields. A serializer that includes everything by default leaks the next field somebody adds. A serializer where the sensitive field must be requested fails safe.
A bug of this kind is invisible to a UI-driven test, because the UI simply never shows the field. Only an API-level assertion catches it.
13. Supabase custom SMTP with Proton
Supabase's built-in mail sender is for testing; it has tight rate limits and a generic sender. For production, auth emails (confirmation, password reset) should come from the app's own address, via custom SMTP in the Supabase dashboard.
The setup mixed up two Proton credentials:
| Proton Bridge | Proton SMTP token | |
|---|---|---|
| For | local mail clients, e.g. the VPS backend | external services, e.g. Supabase |
| Host / port | 127.0.0.1:1025 (local only) | smtp.protonmail.ch:587 |
| Username | your Proton account | the token's address (the sender) |
| Password | a Bridge-generated password | the token's secret |
The .env values worked for the Flask backend, because the Bridge runs on
the same VPS. Pasted into Supabase (a cloud service) they could never work:
127.0.0.1 there is Supabase's own machine, and a Bridge password is not
valid at smtp.protonmail.ch. The port in the screenshot, 1025, gave it
away.
Further details worth remembering:
- A token's secret is shown once. The old token's secret was lost, so
the fix was to create a new token (Settings → IMAP/SMTP → SMTP tokens) for
calisthenics-progression@sixt.servicesand use that address as the username and sender. A mismatch between sender and token address gets mails rejected. - Delivery to inboxes (not spam) depends on SPF, DKIM and DMARC for the sending domain.
- Never expose a Bridge to the internet to make this work.
The test: trigger "forgot password" for a test account; the mail arrived.
Learn more: Supabase — custom SMTP, Proton — SMTP submission
14. App Review round one: "Information Needed"
The first submission came back as Guideline 2.1 — Information Needed. This is not a rejection of the app. A developer account with little review history gets extra scrutiny, and Apple asks for evidence before deciding. The request:
- A screen recording on a physical device, latest OS, starting with app launch: registration, login, account deletion, and the report/block mechanism.
- App purpose and target audience.
- Instructions and demo credentials.
- Every external service used.
- Regional differences (or a statement that there are none).
- Documentation for regulated industries (not applicable).
The answers are kept as a template in store-assets/listing.md. The
practical details:
- Record on the iPhone, not the simulator: Control Center → Screen Recording, then launch the app from the home screen.
- Account deletion must be shown, but not on the demo account. Deleting the account the reviewer needs to sign in with would block the review. Use a throwaway account.
- Reporting and blocking need a second user with a public workout, or Explore has nothing to report. A second demo user went into the notes.
- External services listed honestly: Supabase (auth, Ireland), Hetzner (API and database, Germany), Proton Mail (SMTP). No payments, ads, analytics or AI.
The submission was resubmitted with the video and notes. At the time of writing the review is pending.
Lesson learned: read the whole App Review message before reacting. "Information Needed" asks for a video and an explanation, not for code changes, and the checklist at the bottom is the real specification.
Learn more: App Review — Common issues
16. Files changed
New files
store-assets/
├── listing.md # store text, URLs, App Review answers
├── APP-STORE-CHECKLIST.md
└── logo-source.png
backend/
├── scripts/seed_demo.py # demo data for screenshots
├── migrations/versions/d4a8e2f1b9c3_add_user_block_report_terms.py
├── project/api/moderation_routes.py
├── project/templates/email/report_received.html
└── tests/integration/test_api_moderation.py
web/src/
├── app/{privacy,support,terms}/page.tsx
├── components/legal/{legal-page,contact-email}.tsx
├── components/marketing/brand-logo.tsx
├── components/social/{report-block-menu,blocked-users-section}.tsx
└── hooks/use-moderation.ts
mobile/src/
├── app/(app)/blocked-users.tsx
├── components/social/ReportBlockMenu.tsx (+ .test.tsx)
└── hooks/use-moderation.ts
Key files modified
| File | What changed |
|---|
| backend/project/models.py | UserBlock, Report, terms_accepted_at, blocked_user_ids, is_blocked_between, guard in is_visible_to, to_dict(include_email) |
| backend/project/api/social_routes.py | block filters on explore, profile, follow, lists, messages |
| backend/project/api/exercise_routes.py | block filter on user=all |
| backend/project/api/auth_routes.py | deletion cleans blocks/reports; own-profile endpoints pass include_email=True |
| backend/project/api/auth_utils.py | terms timestamp recorded from signup metadata |
| backend/project/cli.py | flask reports list / resolve |
| web/src/proxy.ts | /privacy, /support, /terms are public |
| web/src/components/layout/navbar.tsx | placeholder SVG replaced by the real logo |
| web/src/app/(auth)/register/page.tsx, mobile/src/app/(auth)/register.tsx | terms acceptance |
| mobile/app.json, mobile/assets/images/*, web/src/app/*icon* | the new icon set; splash width 140 |
| mobile/eas.json | submit.production.ios.ascAppId |
| */i18n/translations/{en,de}.ts | legal.*, auth.acceptTerms, moderation.* |
Next up: the App Review decision. After approval — a re-consent flow for
existing users (the accept-terms endpoint is ready), rate limiting on
messages and reports, real branding on the splash screen.