amber-backend/pb_migrations/1790500000_providers.js
Claude 57585d8ad2 Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.

Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.

The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.

State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.

Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.

byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.

Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:36:55 +02:00

81 lines
4 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// The services a BYOC user has to buy, described in data rather than in markup.
//
// **Why a collection and not copy in index.html.** Two of these fields go stale
// on somebody else's schedule: the price, and the click-path through a signup
// form the provider is free to redesign. A wrong price on our own page is worse
// than no price — it reads as a promise — and the fix must not require editing
// HTML and redeploying the backend the family streams through. Everything here
// is editable in the admin UI, and `priceCheckedAt` is rendered next to the
// number so a stale figure is visibly stale instead of quietly wrong.
//
// **Why not onboarding_template.** That collection holds the family's actual
// credentials and is superuser-only on every rule for that reason. Public-facing
// marketing copy has no business sharing a table with secrets, and widening its
// read rule to serve a price list is exactly the kind of accident this comment
// exists to prevent.
//
// **Why `kind`.** TMDB is not a source and is not optional — no key means no
// posters and no descriptions — so the chooser must not offer it as one of three
// things to weigh up. `source` rows are choices; `metadata` rows are steps that
// happen regardless. Keeping it a token rather than a boolean leaves room for a
// third kind without another migration.
//
// **Why `price` is text.** These are quoted in different currencies and
// different periods ("5 $/měsíc", "199 Kč/měsíc", "$30/rok"), and the page only
// ever displays it. A number plus a currency column would be three fields to get
// wrong in exchange for arithmetic nobody does.
//
// Read is limited to signed-in users. There is no public signup — accounts are
// owner-created — so nobody who cannot log in has any use for this, and the
// tighter rule is free. Writing stays superuser-only: this is content the owner
// maintains, not something a user submits.
migrate(
(app) => {
const c = new Collection({
type: "base",
name: "providers",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
// torbox | prehrajto | webshare | tmdb. The page keys its per-service
// wiring off this, so it is the one field that must not be edited
// casually — hence the note here rather than only in the UI.
{ type: "text", name: "slug", required: true, max: 40 },
{ type: "text", name: "name", required: true, max: 80 },
{ type: "url", name: "url", required: true },
{ type: "text", name: "kind", required: true, max: 20 },
// One line: what this unlocks, in the viewer's terms.
{ type: "text", name: "tagline", max: 300 },
{ type: "json", name: "pros", maxSize: 4000 },
{ type: "json", name: "cons", maxSize: 4000 },
{ type: "text", name: "price", max: 120 },
{ type: "text", name: "priceNote", max: 300 },
{ type: "date", name: "priceCheckedAt" },
// Ordered strings, rendered as a numbered list. Plain text on purpose:
// this is read by someone with a signup form open in the next tab, and
// markup would be one more thing to get wrong in an admin textarea.
{ type: "json", name: "steps", maxSize: 20000 },
{ type: "number", name: "sort" },
// A row is hidden rather than deleted when a service stops being worth
// recommending, so its guide survives for anyone who still has it.
{ type: "bool", name: "enabled" },
{ type: "bool", name: "recommended" },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_providers_slug ON providers (slug)",
],
})
app.save(c)
},
(app) => {
app.delete(app.findCollectionByNameOrId("providers"))
},
)