From Flask Monolith to Multiplatform App: Migration Journey Part 5

learningexporeactdocsios

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

  1. What we're building
  2. Tech stack additions
  3. A real logo, and the asset pipeline behind it
  4. Why the new icon didn't show up
  5. Legal pages: privacy, support, terms
  6. Putting the logo into the UI
  7. The App Store Connect listing
  8. EAS submit, ascAppId and version numbers
  9. Demo data and store screenshots
  10. Guideline 1.2, backend: blocking and reporting
  11. Guideline 1.2, clients: terms checkbox and report/block menu
  12. The email leak
  13. Supabase custom SMTP with Proton
  14. App Review round one: "Information Needed"
  15. Bugs that bit us
  16. 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

TechnologyRoleLearn more
ImageMagick (magick)Derive every icon size from one source imageImageMagick
xcrun simctlSet a clean status bar and take simulator screenshotssimctl
next/imageLogo above titles, no layout shiftnext/image
@headlessui/react Menu + DialogThe "..." report/block menu on webHeadless UI
expo-web-browserOpens privacy/terms/support in an in-app browserexpo-web-browser
UserBlock, Report modelsBlocking and reporting, backend sideSQLAlchemy ORM
Alembic revision d4a8e2f1b9c3Adds both tables and user.terms_accepted_atAlembic
Flask CLI group reportsReview and resolve reports from the shellFlask CLI
Supabase custom SMTP + ProtonAuth emails from our own domainSupabase 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:

TargetRule
iOS app icon1024², no alpha, no rounded corners — Apple masks it
Android adaptive iconforeground on transparent, background a flat colour, glyph inside the central safe zone
Splashglyph only, on the splash background colour
Web favicon.ico16/32/48 px in one file
Web PWA/apple icons192, 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.json or assets/ 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 generated ios/ 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=14day in journald.conf on 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


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-block shrinks the wrapper to its widest child.
  • w-0 makes the image contribute no intrinsic width, so the title text alone decides the width.
  • min-w-full then stretches the image to that width, and h-auto keeps 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

NumberSourceWho 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 --reset never 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:

  1. User.query raised AttributeError: this project's models use a plain SQLAlchemy 2.0 Base, not Flask-SQLAlchemy's db.Model, so the legacy .query accessor doesn't exist. The script uses db.session.scalar(select(...)) instead.
  2. 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:

EndpointBlock handling
GET /exploreWorkout.user_id.not_in(blocked_ids)
GET /users/<u>404 if blocked either way
POST /users/<u>/follow404 if blocked
followers / following / follow-requestsrows filtered; 404 for a blocked target
GET /messagesMessage.sender_id.not_in(blocked_ids)
POST /messages/<u>404 if blocked
GET /exercises?user=allExerciseDefinition.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 with db.create_all(), dropped the new tables, ran flask db stamp c1c659752107, and then tested upgrade → downgrade → upgrade.
  • flask db downgrade -1 is 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, a Dialog for the report reason and details, and the existing ConfirmDialog for blocking.
  • Mobile: Alert.alert for the action list and the block confirmation (the platform idiom, like delete confirmations in Part 4), and a pageSheet Modal for 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.press is async and must be awaited; unawaited calls produced "overlapping act() calls" and one run that hung the whole worker.
  • The callbacks passed to Alert.alert run outside React's test environment, so calling them needs await 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 BridgeProton SMTP token
Forlocal mail clients, e.g. the VPS backendexternal services, e.g. Supabase
Host / port127.0.0.1:1025 (local only)smtp.protonmail.ch:587
Usernameyour Proton accountthe token's address (the sender)
Passworda Bridge-generated passwordthe 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.services and 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:

  1. A screen recording on a physical device, latest OS, starting with app launch: registration, login, account deletion, and the report/block mechanism.
  2. App purpose and target audience.
  3. Instructions and demo credentials.
  4. Every external service used.
  5. Regional differences (or a statement that there are none).
  6. 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

FileWhat 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.