Vectrum
Sign In
83 lines · 3.2 KB · JavaScript
// Organizations, like on GitHub: accounts that own repositories for a group of people.
//
// An organization is a record in `users` with kind "org", so it shares the namespace of usernames
// and repositories keep pointing at their owner the same way (/<org>/<repo>). Nobody signs in as
// one: it has no email and a random password, and the auth rule refuses it anyway.
//
// `org_members` gives people a role in it, which is their access to all of its repositories:
// read, write, admin (also creates, configures and deletes repositories) or owner (also manages
// the organization and its members). People join by accepting an invitation.

const ROLES = ["owner", "admin", "write", "read"];

const number = (name, opts = {}) => ({ type: "number", name, onlyInt: true, ...opts });
const time = (name, opts = {}) => number(name, { min: 0, ...opts });
const select = (name, values, opts = {}) => ({ type: "select", name, values, maxSelect: 1, required: true, ...opts });
const relation = (name, collection, opts = {}) => ({
  type: "relation",
  name,
  collectionId: collection.id,
  maxSelect: 1,
  cascadeDelete: false,
  ...opts,
});
const parent = (name, collection) => relation(name, collection, { required: true, cascadeDelete: true });

function create(app, name, fields, indexes = []) {
  const collection = new Collection({
    type: "base",
    name,
    listRule: null,
    viewRule: null,
    createRule: null,
    updateRule: null,
    deleteRule: null,
    fields,
    indexes,
  });
  app.save(collection);
  return collection;
}

migrate(
  (app) => {
    const users = app.findCollectionByNameOrId("users");
    // "" for a person.
    users.fields.add(new SelectField({ name: "kind", values: ["org"], maxSelect: 1 }));
    // Organizations have none; the unique index on email skips empty ones.
    users.fields.getByName("email").required = false;
    users.authRule = "kind != 'org'";
    app.save(users);

    create(
      app,
      "org_members",
      [parent("org", users), parent("user", users), select("role", ROLES), time("created_at", { required: true })],
      ["CREATE UNIQUE INDEX idx_org_members ON org_members (org, user)", "CREATE INDEX idx_org_members_user ON org_members (user)"],
    );

    // Pending until the person invited accepts (and becomes a member) or declines.
    create(
      app,
      "org_invitations",
      [
        parent("org", users),
        parent("user", users),
        select("role", ROLES),
        relation("invited_by", users, { cascadeDelete: true }),
        time("created_at", { required: true }),
      ],
      ["CREATE UNIQUE INDEX idx_org_invitations ON org_invitations (org, user)", "CREATE INDEX idx_org_invitations_user ON org_invitations (user)"],
    );
  },
  (app) => {
    for (const name of ["org_invitations", "org_members"]) app.delete(app.findCollectionByNameOrId(name));
    // Organizations can't be people: they go, and their repositories with them.
    for (const org of app.findRecordsByFilter("users", "kind = 'org'")) app.delete(org);
    const users = app.findCollectionByNameOrId("users");
    users.fields.removeByName("kind");
    users.fields.getByName("email").required = true;
    users.authRule = "";
    app.save(users);
  },
);