/// // Initial account + multi-profile schema for myanime-app (epic #6, issue #9). // // Ownership model: users 1─┬─* profiles ─┬─* watch_state // │ ├─* watchlist // │ └─1 prefs // // Access rules: a user only ever touches their own `users` row, their own // profiles, and rows hanging off those profiles (`profile.user = auth.id`). // `nsfwEnabled` is admin-only: users can't submit it (superusers bypass rules // and set it from the admin UI). It gates adult vs clean builds in issue #16. migrate((app) => { // ---- 1. Augment the default `users` auth collection -------------------- const users = app.findCollectionByNameOrId("users") users.fields.add(new Field({ type: "text", name: "username", max: 150, presentable: true, })) users.fields.add(new Field({ type: "bool", name: "nsfwEnabled", })) // Unique username, but the field is optional — only enforce when non-empty. users.indexes.push( "CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`) WHERE `username` != ''" ) // Own record only; nsfwEnabled never accepted from a client on create/update. users.listRule = "id = @request.auth.id" users.viewRule = "id = @request.auth.id" users.createRule = "@request.body.nsfwEnabled:isset = false" users.updateRule = "id = @request.auth.id && @request.body.nsfwEnabled:isset = false" users.deleteRule = "id = @request.auth.id" app.save(users) const usersId = users.id // ---- 2. profiles ------------------------------------------------------- const profiles = new Collection({ type: "base", name: "profiles", listRule: "@request.auth.id != '' && user = @request.auth.id", viewRule: "@request.auth.id != '' && user = @request.auth.id", createRule: "@request.auth.id != '' && user = @request.auth.id", updateRule: "@request.auth.id != '' && user = @request.auth.id", deleteRule: "@request.auth.id != '' && user = @request.auth.id", fields: [ { type: "relation", name: "user", required: true, collectionId: usersId, cascadeDelete: true, maxSelect: 1, minSelect: 0, }, { type: "text", name: "name", required: true, max: 100, presentable: true }, { type: "file", name: "avatar", maxSelect: 1, maxSize: 5242880, mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"], }, { type: "bool", name: "isChild" }, // PIN for child profiles; hidden so it never leaves the API. { type: "text", name: "pinHash", hidden: true, max: 200 }, { type: "autodate", name: "created", onCreate: true }, { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, ], }) app.save(profiles) const profilesId = profiles.id // Rows below all belong to a profile; ownership traverses profile.user. const OWNS = "@request.auth.id != '' && profile.user = @request.auth.id" const profileRel = () => ({ type: "relation", name: "profile", required: true, collectionId: profilesId, cascadeDelete: true, maxSelect: 1, minSelect: 0, }) // ---- 3. watch_state (resume points) ------------------------------------ const watchState = new Collection({ type: "base", name: "watch_state", listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS, fields: [ profileRel(), { type: "text", name: "itemId", required: true, max: 100 }, { type: "text", name: "itemType", required: true, max: 40 }, { type: "number", name: "position", min: 0 }, { type: "number", name: "duration", min: 0 }, { type: "autodate", name: "updatedAt", onCreate: true, onUpdate: true }, ], indexes: [ "CREATE UNIQUE INDEX `idx_watch_state_profile_item` ON `watch_state` (`profile`, `itemId`)", ], }) app.save(watchState) // ---- 4. watchlist ------------------------------------------------------ const watchlist = new Collection({ type: "base", name: "watchlist", listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS, fields: [ profileRel(), { type: "text", name: "itemId", required: true, max: 100 }, { type: "text", name: "itemType", required: true, max: 40 }, { type: "autodate", name: "addedAt", onCreate: true }, ], indexes: [ "CREATE UNIQUE INDEX `idx_watchlist_profile_item` ON `watchlist` (`profile`, `itemId`)", ], }) app.save(watchlist) // ---- 5. prefs (one row per profile) ------------------------------------ const prefs = new Collection({ type: "base", name: "prefs", listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS, fields: [ profileRel(), // playback / audio / subtitle preferences blob. { type: "json", name: "data", maxSize: 2000000 }, { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, ], indexes: [ "CREATE UNIQUE INDEX `idx_prefs_profile` ON `prefs` (`profile`)", ], }) app.save(prefs) }, (app) => { // ---- Down migration ---------------------------------------------------- for (const name of ["prefs", "watchlist", "watch_state", "profiles"]) { try { app.delete(app.findCollectionByNameOrId(name)) } catch (_) { /* already gone */ } } const users = app.findCollectionByNameOrId("users") users.indexes = users.indexes.filter((idx) => !idx.includes("idx_users_username")) users.fields.removeByName("username") users.fields.removeByName("nsfwEnabled") users.createRule = "" users.updateRule = "id = @request.auth.id" users.listRule = "id = @request.auth.id" users.viewRule = "id = @request.auth.id" users.deleteRule = "id = @request.auth.id" app.save(users) })