What it takes to let students sell textbooks to each other
Adding a marketplace to a study app is easy. Adding one that is allowed to exist is a different problem — reporting, blocking, review queues and a daily cap.
The Store started as a simple idea: students have textbooks, calculators, drafters and lab kits they no longer need, and other students need them. A listing form and a browse list, and it works.
Then we read the policy. User-generated content on Google Play comes with obligations, and a marketplace where one student can contact another is squarely inside them. Two of those obligations are non-negotiable: a way to report a listing, and a way to block a seller.
Reporting had to work signed out
That constraint shaped the whole design.
It is tempting to require a sign-in before someone can report a listing. It is also the wrong call — the person most motivated to report something is often the one who does not want to be identified, and an app that makes them create an account first has effectively made reporting hard.
So reporting is open to anyone, including a signed-out user, and the identity fields on a report are stamped by a database function rather than sent by the client. The table's insert policy forces those fields to be empty for direct writes, so a signed-in user cannot file a report under someone else's name, and an anonymous reporter simply has no identity attached.
This shows up in Supabase's security advisor as a warning: a function callable by anonymous users. That warning is correct in general and intentional here. Reporting has to be reachable by someone who is not signed in.
Duplicates, and the ones you cannot deduplicate
Someone who reports a listing twice should not create two reports. A uniqueness constraint on the reporter and the listing handles that, and the second attempt is swallowed rather than surfaced as an error — from the reporter's point of view, the report was already made, which is true.
Anonymous reports cannot be deduplicated at all. There is nobody to deduplicate against. We accepted that: the alternative is requiring sign-in, which we ruled out above.
Blocking a seller, without a name to block
The block list has a small problem baked into it. Our profiles table is owner-only — a user can read their own profile and nothing else. So a block list cannot look up the name of a seller it has blocked, because it is not allowed to read that row.
The name is therefore copied onto the block row at the moment of blocking, when it is still legitimately visible. Denormalised on purpose, because the alternative is a block list showing blank entries.
Blocking also cannot be a server-side flag. It is per-user: the same listing is hidden for one student and visible for everyone else. So the filter runs on the device when the browse list loads, which is why that fetch is asynchronous and why the product card has a callback to re-fetch after something is hidden.
And blocking never throws. If the block list fails to load, the Store still opens. A moderation feature that can take down the thing it protects is not an improvement.
Nothing goes live unreviewed
Report and block handle what gets through. The other half is not letting things through in the first place, so every new listing is held for review before it appears.
Enforcing that properly turned out to be subtler than it looks. The rule we wanted is: a seller may edit their own listing, but may not change its review status. Row-level security cannot express that, because a policy sees a row, not the difference between the old row and the new one. It cannot say “this column must not change”.
A database trigger can. A rule running before every update forces a non-admin's status back to whatever it was, so a seller editing their own title cannot approve themselves in the same request. The timestamp and the reviewer are stamped at the same moment the status changes, and only then.
Which is also why those fields are read-only in the admin panel. A form that could set a status without recording who set it — or record a reviewer without changing the status — would produce rows whose audit trail contradicts their own state.
A cap that is not in the app
Review queues do not help if someone can post fifty listings while you sleep. Listings are capped per person per day, and the cap has two design decisions worth naming.
It counts listings created, not listings currently live. Counting live rows means a spammer can delete and repost indefinitely, staying under the limit while flooding the store. Deleting a listing therefore does not refund a slot, which is deliberate.
And the number itself lives in a database function, which the app reads back rather than hard-coding. Raising the cap from three to five is one function in the database — no new app release, and no version of the app left enforcing a rule the server has changed.
The rule about missing values
One small decision that would have caused a lot of visible damage: a listing with no review status must read as live, not as pending.
The column was added to a table that already had listings, and the browse list is cached on devices. A row cached before the column existed carries nothing for it, and so does a row from an app version that predates it. Treating an absent status as “in review” would label every browsed listing as unpublished. An unrecognised status is treated the same way, so a value added on the server later cannot make a live listing look rejected on phones that are already out there.
The pattern underneath
Most of these are the same lesson in different clothes. A moderation rule has to hold against a client that is not cooperating — so the enforcement belongs in the database, the app reads the rules back instead of carrying its own copy, and anything the client cannot know (who is asking, what changed) is decided by the server. The client's job is to be pleasant about it.
Keep reading
Related reading
A topic you have not opened in a month still says 'Yesterday'
Daily Revision schedules reviews on a five-rung ladder and then labels them with times that cannot be right. An item untouched for a month still reads "Yesterday".
The card you mark as known never comes back
Flashcards has three states and no spaced-repetition scheduler. Marking a card known removes it from revision permanently, and four different screens disagree about which cards are due.
Every rule that makes a timetable a timetable is English text in a prompt
We went looking for the scheduler in our AI study timetable and there is not one. No overlap detection, no chronological sort, no feasibility check — the plan is whatever the model returns, and the rules exist only as sentences asking it to behave.