/// // 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")) }, )