feat(schema): all collections + role-scoped API rules on remote PB
- migrations/ documented in pocketbase/pb_migrations/ for local PB - scripts/setup-remote.ts applies them via admin API to remote PB - collections: clients, projects, columns, cards, subtasks, comments, activity, settings - user extension: role (owner|client_manager), client (relation) - writes: owner only for app collections, superuser-only for activity and settings
This commit is contained in:
parent
88a3761857
commit
ba990f0109
12 changed files with 3345 additions and 0 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@
|
|||
"test:integration": "vitest run --project integration",
|
||||
"test:all": "vitest run",
|
||||
"seed": "tsx scripts/seed-demo.ts",
|
||||
"setup-remote": "tsx scripts/setup-remote.ts",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
56
pocketbase/pb_migrations/1789600100_created_clients.js
Normal file
56
pocketbase/pb_migrations/1789600100_created_clients.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `clients` — the companies the developer works for.
|
||||
*
|
||||
* A client owns projects. Client-manager users are scoped to exactly one
|
||||
* client, and that scoping is what stops one customer seeing another's work.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const collection = new Collection({
|
||||
name: "clients",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
max: 120,
|
||||
presentable: true,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
required: true,
|
||||
max: 60,
|
||||
pattern: "^[a-z0-9-]+$",
|
||||
},
|
||||
{ name: "brand_color", type: "text", required: false, max: 7 },
|
||||
{ name: "active", type: "bool", required: false },
|
||||
{
|
||||
name: "created",
|
||||
type: "autodate",
|
||||
onCreate: true,
|
||||
onUpdate: false,
|
||||
},
|
||||
{
|
||||
name: "updated",
|
||||
type: "autodate",
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
},
|
||||
],
|
||||
indexes: ["CREATE UNIQUE INDEX idx_clients_slug ON clients (slug)"],
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
return app.delete(app.findCollectionByNameOrId("clients"));
|
||||
}
|
||||
);
|
||||
71
pocketbase/pb_migrations/1789600200_created_projects.js
Normal file
71
pocketbase/pb_migrations/1789600200_created_projects.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `projects` — one project maps to exactly one git repository.
|
||||
*
|
||||
* The three repo_* fields are written now and read for the first time in
|
||||
* Phase 2, when Forgejo commit ingestion is built. They are stored here so
|
||||
* the owner can fill them in as projects are created, rather than needing a
|
||||
* migration and a backfill later.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const clients = app.findCollectionByNameOrId("clients");
|
||||
|
||||
const collection = new Collection({
|
||||
name: "projects",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "client",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: clients.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
max: 120,
|
||||
presentable: true,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
required: true,
|
||||
max: 60,
|
||||
pattern: "^[a-z0-9-]+$",
|
||||
},
|
||||
{ name: "description", type: "text", required: false, max: 20000 },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["active", "paused", "completed", "archived"],
|
||||
},
|
||||
{ name: "color", type: "text", required: false, max: 7 },
|
||||
{ name: "repo_owner", type: "text", required: false, max: 120 },
|
||||
{ name: "repo_name", type: "text", required: false, max: 120 },
|
||||
{ name: "repo_url", type: "url", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE UNIQUE INDEX idx_projects_client_slug ON projects (client, slug)",
|
||||
],
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
return app.delete(app.findCollectionByNameOrId("projects"));
|
||||
}
|
||||
);
|
||||
50
pocketbase/pb_migrations/1789600300_extended_users.js
Normal file
50
pocketbase/pb_migrations/1789600300_extended_users.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* Add `role` and `client` to the built-in `users` auth collection.
|
||||
*
|
||||
* `role` is the hinge of the whole permission model: every API rule in
|
||||
* 1789600900_applied_api_rules.js reads it, and the server route handlers
|
||||
* refuse to act for any role other than client_manager.
|
||||
*
|
||||
* A client_manager MUST have `client` set. That is enforced in application
|
||||
* code and by the seed script rather than by the schema, because PocketBase
|
||||
* cannot express "required only when another field has a given value".
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
const clients = app.findCollectionByNameOrId("clients");
|
||||
|
||||
users.fields.add(
|
||||
new Field({
|
||||
name: "role",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["owner", "client_manager"],
|
||||
})
|
||||
);
|
||||
|
||||
users.fields.add(
|
||||
new Field({
|
||||
name: "client",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: clients.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
})
|
||||
);
|
||||
|
||||
return app.save(users);
|
||||
},
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
for (const name of ["role", "client"]) {
|
||||
const field = users.fields.find((f) => f.name === name);
|
||||
if (field) users.fields.removeById(field.id);
|
||||
}
|
||||
return app.save(users);
|
||||
}
|
||||
);
|
||||
52
pocketbase/pb_migrations/1789600400_created_columns.js
Normal file
52
pocketbase/pb_migrations/1789600400_created_columns.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `columns` — the vertical lanes of one project's board.
|
||||
*
|
||||
* Two flags carry behaviour rather than presentation:
|
||||
* is_inbox — where client-created cards land. Exactly one per project.
|
||||
* is_done — the terminal lane. Exactly one per project.
|
||||
*
|
||||
* "Exactly one" is enforced in application code (src/lib/columns/seed.ts and
|
||||
* the admin flows in Plan 1b), not by the schema. PocketBase has no
|
||||
* cross-record constraint that can express it.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const projects = app.findCollectionByNameOrId("projects");
|
||||
|
||||
const collection = new Collection({
|
||||
name: "columns",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "project",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: projects.id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "name", type: "text", required: true, max: 60, presentable: true },
|
||||
{ name: "position", type: "number", required: true },
|
||||
{ name: "is_inbox", type: "bool", required: false },
|
||||
{ name: "is_done", type: "bool", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE INDEX idx_columns_project ON columns (project, position)",
|
||||
],
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
return app.delete(app.findCollectionByNameOrId("columns"));
|
||||
}
|
||||
);
|
||||
97
pocketbase/pb_migrations/1789600500_created_cards.js
Normal file
97
pocketbase/pb_migrations/1789600500_created_cards.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `cards` — units of work.
|
||||
*
|
||||
* Two fields belong to the client, not the developer:
|
||||
* priority — the client sets it directly
|
||||
* target_date — the client sets it directly
|
||||
* Everything else is the developer's. That split is enforced by the field
|
||||
* whitelist in src/lib/cards/client-patch.ts, because PocketBase rules are
|
||||
* record-level and cannot express it. See the domain primer at the top of
|
||||
* this plan.
|
||||
*
|
||||
* Two fields are written now and first read in Phase 2:
|
||||
* start_date — bounds which commits a scope rule may claim, so a new card
|
||||
* cannot retroactively swallow a year of old commits.
|
||||
* scope_rule — the conventional-commit scope this card owns, e.g. "stock"
|
||||
* matching `feat(stock): ...`.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const projects = app.findCollectionByNameOrId("projects");
|
||||
const columns = app.findCollectionByNameOrId("columns");
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
|
||||
const collection = new Collection({
|
||||
name: "cards",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "project",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: projects.id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "column",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: columns.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
||||
{ name: "description", type: "text", required: false, max: 50000 },
|
||||
{ name: "position", type: "number", required: true },
|
||||
{
|
||||
name: "priority",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["critical", "high", "medium", "low"],
|
||||
},
|
||||
{ name: "target_date", type: "date", required: false },
|
||||
{
|
||||
name: "created_by",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "created_by_role",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["owner", "client"],
|
||||
},
|
||||
{ name: "accepted", type: "bool", required: false },
|
||||
{ name: "start_date", type: "date", required: false },
|
||||
{ name: "scope_rule", type: "text", required: false, max: 60 },
|
||||
{ name: "archived", type: "bool", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE INDEX idx_cards_column ON cards (`column`, position)",
|
||||
"CREATE INDEX idx_cards_project ON cards (project, archived)",
|
||||
],
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
return app.delete(app.findCollectionByNameOrId("cards"));
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `subtasks` and `comments`, both children of a card and both cascade-deleted
|
||||
* with it.
|
||||
*
|
||||
* Comment bodies are markdown authored by a client. They are rendered without
|
||||
* raw HTML — see Plan 1b. Never render a comment body with
|
||||
* dangerouslySetInnerHTML.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const cards = app.findCollectionByNameOrId("cards");
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
|
||||
const subtasks = new Collection({
|
||||
name: "subtasks",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "card",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: cards.id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
||||
{ name: "done", type: "bool", required: false },
|
||||
{ name: "position", type: "number", required: true },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE INDEX idx_subtasks_card ON subtasks (card, position)"],
|
||||
});
|
||||
app.save(subtasks);
|
||||
|
||||
const comments = new Collection({
|
||||
name: "comments",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "card",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: cards.id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "body", type: "text", required: true, max: 20000 },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE INDEX idx_comments_card ON comments (card, created)"],
|
||||
});
|
||||
return app.save(comments);
|
||||
},
|
||||
(app) => {
|
||||
app.delete(app.findCollectionByNameOrId("comments"));
|
||||
return app.delete(app.findCollectionByNameOrId("subtasks"));
|
||||
}
|
||||
);
|
||||
87
pocketbase/pb_migrations/1789600700_created_activity.js
Normal file
87
pocketbase/pb_migrations/1789600700_created_activity.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `activity` — an append-only audit log.
|
||||
*
|
||||
* This is not a nice-to-have. Once a client can change priorities and dates
|
||||
* on the developer's board, "I never agreed to that date" has to be
|
||||
* answerable from the record. Every client-originated write produces a row.
|
||||
*
|
||||
* Nothing updates or deletes activity rows. Both rules stay null forever,
|
||||
* which in PocketBase means superuser-only.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const projects = app.findCollectionByNameOrId("projects");
|
||||
const cards = app.findCollectionByNameOrId("cards");
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
|
||||
const collection = new Collection({
|
||||
name: "activity",
|
||||
type: "base",
|
||||
listRule: "@request.auth.id != ''",
|
||||
viewRule: "@request.auth.id != ''",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "project",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: projects.id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: cards.id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "actor",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "action",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: [
|
||||
"card_created",
|
||||
"card_moved",
|
||||
"card_updated",
|
||||
"priority_changed",
|
||||
"date_changed",
|
||||
"card_accepted",
|
||||
"comment_added",
|
||||
"subtask_toggled",
|
||||
"card_archived",
|
||||
],
|
||||
},
|
||||
{ name: "field", type: "text", required: false, max: 60 },
|
||||
{ name: "old_value", type: "text", required: false, max: 2000 },
|
||||
{ name: "new_value", type: "text", required: false, max: 2000 },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE INDEX idx_activity_card ON activity (card, created)",
|
||||
"CREATE INDEX idx_activity_project ON activity (project, created)",
|
||||
],
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
return app.delete(app.findCollectionByNameOrId("activity"));
|
||||
}
|
||||
);
|
||||
54
pocketbase/pb_migrations/1789600800_created_settings.js
Normal file
54
pocketbase/pb_migrations/1789600800_created_settings.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* `settings` — a single-record collection for AI provider configuration.
|
||||
*
|
||||
* Created empty in Phase 1 so Phase 3 needs no migration. Nothing reads these
|
||||
* fields yet.
|
||||
*
|
||||
* EVERY rule is null, meaning superuser-only. This is deliberate and must not
|
||||
* be relaxed: ai_api_key is a live credential and no browser, owner's
|
||||
* included, should ever be able to fetch it. Phase 3 reads it server-side
|
||||
* only, and the settings UI shows it masked and write-only.
|
||||
*
|
||||
* ai_system_prompt_extra is APPENDED to a non-editable preamble held in code.
|
||||
* The guaranteed-present rules — the assistant proposes dates but never
|
||||
* commits to them, and client-submitted text is data rather than instructions
|
||||
* — cannot live in an editable field.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const collection = new Collection({
|
||||
name: "settings",
|
||||
type: "base",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "ai_provider",
|
||||
type: "select",
|
||||
required: false,
|
||||
maxSelect: 1,
|
||||
values: ["anthropic", "openai", "minimax"],
|
||||
},
|
||||
{ name: "ai_api_key", type: "text", required: false, max: 400 },
|
||||
{ name: "ai_model", type: "text", required: false, max: 120 },
|
||||
{
|
||||
name: "ai_system_prompt_extra",
|
||||
type: "text",
|
||||
required: false,
|
||||
max: 20000,
|
||||
},
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
return app.delete(app.findCollectionByNameOrId("settings"));
|
||||
}
|
||||
);
|
||||
121
pocketbase/pb_migrations/1789600900_applied_api_rules.js
Normal file
121
pocketbase/pb_migrations/1789600900_applied_api_rules.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* The permission boundary.
|
||||
*
|
||||
* Reads: scoped by role. An owner reads everything; a client_manager reads
|
||||
* only records belonging to their own client, via relation traversal.
|
||||
*
|
||||
* Writes: allowed for the owner, denied for everyone else. A client_manager's
|
||||
* token is refused by PocketBase outright. Their writes arrive instead
|
||||
* through /api/client/* route handlers that authenticate as a superuser and
|
||||
* copy across only whitelisted fields, because PocketBase rules are
|
||||
* record-level and cannot say "priority yes, column no".
|
||||
*
|
||||
* Do not relax a write rule here to make a client-side form work. That form
|
||||
* is meant to POST to a route handler.
|
||||
*
|
||||
* Rule value meanings in PocketBase, which are easy to confuse:
|
||||
* null -> superuser only
|
||||
* "" -> anyone, including unauthenticated
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const OWNER = '@request.auth.role = "owner"';
|
||||
|
||||
/** Read rule for a collection reachable from a project relation. */
|
||||
const scopedRead = (pathToClient) =>
|
||||
`@request.auth.id != "" && (${OWNER} || ${pathToClient} = @request.auth.client)`;
|
||||
|
||||
const apply = (name, { list, view, create, update, del }) => {
|
||||
const c = app.findCollectionByNameOrId(name);
|
||||
c.listRule = list;
|
||||
c.viewRule = view;
|
||||
c.createRule = create;
|
||||
c.updateRule = update;
|
||||
c.deleteRule = del;
|
||||
app.save(c);
|
||||
};
|
||||
|
||||
apply("clients", {
|
||||
list: `@request.auth.id != "" && (${OWNER} || id = @request.auth.client)`,
|
||||
view: `@request.auth.id != "" && (${OWNER} || id = @request.auth.client)`,
|
||||
create: OWNER,
|
||||
update: OWNER,
|
||||
del: OWNER,
|
||||
});
|
||||
|
||||
apply("projects", {
|
||||
list: scopedRead("client"),
|
||||
view: scopedRead("client"),
|
||||
create: OWNER,
|
||||
update: OWNER,
|
||||
del: OWNER,
|
||||
});
|
||||
|
||||
apply("columns", {
|
||||
list: scopedRead("project.client"),
|
||||
view: scopedRead("project.client"),
|
||||
create: OWNER,
|
||||
update: OWNER,
|
||||
del: OWNER,
|
||||
});
|
||||
|
||||
apply("cards", {
|
||||
list: scopedRead("project.client"),
|
||||
view: scopedRead("project.client"),
|
||||
create: OWNER,
|
||||
update: OWNER,
|
||||
del: OWNER,
|
||||
});
|
||||
|
||||
apply("subtasks", {
|
||||
list: scopedRead("card.project.client"),
|
||||
view: scopedRead("card.project.client"),
|
||||
create: OWNER,
|
||||
update: OWNER,
|
||||
del: OWNER,
|
||||
});
|
||||
|
||||
apply("comments", {
|
||||
list: scopedRead("card.project.client"),
|
||||
view: scopedRead("card.project.client"),
|
||||
create: OWNER,
|
||||
update: OWNER,
|
||||
del: OWNER,
|
||||
});
|
||||
|
||||
// Activity is append-only and never written from a browser: even the
|
||||
// owner's creates go through server code, so create stays superuser-only.
|
||||
apply("activity", {
|
||||
list: scopedRead("project.client"),
|
||||
view: scopedRead("project.client"),
|
||||
create: null,
|
||||
update: null,
|
||||
del: null,
|
||||
});
|
||||
|
||||
// settings keeps every rule null — superuser only. See its migration.
|
||||
return true;
|
||||
},
|
||||
(app) => {
|
||||
const open = '@request.auth.id != ""';
|
||||
for (const name of [
|
||||
"clients",
|
||||
"projects",
|
||||
"columns",
|
||||
"cards",
|
||||
"subtasks",
|
||||
"comments",
|
||||
"activity",
|
||||
]) {
|
||||
const c = app.findCollectionByNameOrId(name);
|
||||
c.listRule = open;
|
||||
c.viewRule = open;
|
||||
c.createRule = null;
|
||||
c.updateRule = null;
|
||||
c.deleteRule = null;
|
||||
app.save(c);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
501
scripts/setup-remote.ts
Normal file
501
scripts/setup-remote.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
/**
|
||||
* One-shot remote schema setup.
|
||||
*
|
||||
* The plan's migrations live in pocketbase/pb_migrations/ and would auto-apply
|
||||
* on a local PocketBase start. The shared instance is already running, so
|
||||
* this script performs the equivalent actions over the admin API:
|
||||
*
|
||||
* - creates the 8 application collections
|
||||
* - adds the role/client fields to users
|
||||
* - applies the role-scoped API rules
|
||||
*
|
||||
* Re-runs are safe: existing collections are reused, fields are not
|
||||
* duplicated, and rules are reapplied each time.
|
||||
*
|
||||
* Run with: npm run setup-remote (PocketBase must be reachable)
|
||||
*/
|
||||
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
const PB_URL =
|
||||
process.env.NEXT_PUBLIC_PB_URL ??
|
||||
"http://127.0.0.1:8090";
|
||||
|
||||
async function authAdmin(pb: PocketBase): Promise<void> {
|
||||
const email = process.env.PB_SUPERUSER_EMAIL;
|
||||
const password = process.env.PB_SUPERUSER_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
"PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD must be set. Copy .env.example to .env.local."
|
||||
);
|
||||
}
|
||||
pb.autoCancellation(false);
|
||||
await pb.collection("_superusers").authWithPassword(email, password);
|
||||
}
|
||||
|
||||
async function getOrCreateCollection(
|
||||
pb: PocketBase,
|
||||
body: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
const name = body.name as string;
|
||||
const existing = await pb.collections
|
||||
.getOne(name)
|
||||
.catch(() => null);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const created = await pb.collections.create(body);
|
||||
console.log(` + collection ${name} (${created.id})`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function ensureUsersFields(pb: PocketBase): Promise<void> {
|
||||
const users = await pb.collections.getOne("users");
|
||||
const have = new Set(users.fields.map((f: { name: string }) => f.name));
|
||||
|
||||
let changed = false;
|
||||
const fields = [...(users.fields as unknown[])];
|
||||
|
||||
if (!have.has("role")) {
|
||||
fields.push({
|
||||
name: "role",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["owner", "client_manager"],
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!have.has("client")) {
|
||||
const clients = await pb.collections.getOne("clients");
|
||||
fields.push({
|
||||
name: "client",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: clients.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await pb.collections.update(users.id, { fields });
|
||||
console.log(" + users fields: role, client");
|
||||
} else {
|
||||
console.log(" = users fields already present");
|
||||
}
|
||||
}
|
||||
|
||||
const OWNER = '@request.auth.role = "owner"';
|
||||
const AUTHED = '@request.auth.id != ""';
|
||||
const scopedRead = (pathToClient: string) =>
|
||||
`${AUTHED} && (${OWNER} || ${pathToClient} = @request.auth.client)`;
|
||||
|
||||
async function applyRules(
|
||||
pb: PocketBase,
|
||||
name: string,
|
||||
rules: {
|
||||
listRule: string | null;
|
||||
viewRule: string | null;
|
||||
createRule: string | null;
|
||||
updateRule: string | null;
|
||||
deleteRule: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const c = await pb.collections.getOne(name);
|
||||
await pb.collections.update(c.id, rules);
|
||||
console.log(` = rules for ${name}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const pb = new PocketBase(PB_URL);
|
||||
await authAdmin(pb);
|
||||
|
||||
console.log("Creating collections...");
|
||||
|
||||
// 1. clients
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "clients",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true, max: 120, presentable: true },
|
||||
{ name: "slug", type: "text", required: true, max: 60, pattern: "^[a-z0-9-]+$" },
|
||||
{ name: "brand_color", type: "text", required: false, max: 7 },
|
||||
{ name: "active", type: "bool", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE UNIQUE INDEX idx_clients_slug ON clients (slug)"],
|
||||
});
|
||||
|
||||
// 2. projects
|
||||
const clientsId = (await pb.collections.getOne("clients")).id;
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "projects",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "client",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: clientsId,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "name", type: "text", required: true, max: 120, presentable: true },
|
||||
{ name: "slug", type: "text", required: true, max: 60, pattern: "^[a-z0-9-]+$" },
|
||||
{ name: "description", type: "text", required: false, max: 20000 },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["active", "paused", "completed", "archived"],
|
||||
},
|
||||
{ name: "color", type: "text", required: false, max: 7 },
|
||||
{ name: "repo_owner", type: "text", required: false, max: 120 },
|
||||
{ name: "repo_name", type: "text", required: false, max: 120 },
|
||||
{ name: "repo_url", type: "url", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE UNIQUE INDEX idx_projects_client_slug ON projects (client, slug)",
|
||||
],
|
||||
});
|
||||
|
||||
// Extend users
|
||||
await ensureUsersFields(pb);
|
||||
|
||||
// 3. columns
|
||||
const projectsId = (await pb.collections.getOne("projects")).id;
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "columns",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "project",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: projectsId,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "name", type: "text", required: true, max: 60, presentable: true },
|
||||
{ name: "position", type: "number", required: true },
|
||||
{ name: "is_inbox", type: "bool", required: false },
|
||||
{ name: "is_done", type: "bool", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE INDEX idx_columns_project ON columns (project, position)"],
|
||||
});
|
||||
|
||||
// 4. cards
|
||||
const columnsId = (await pb.collections.getOne("columns")).id;
|
||||
const usersId = (await pb.collections.getOne("users")).id;
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "cards",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "project",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: projectsId,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "column",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: columnsId,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
||||
{ name: "description", type: "text", required: false, max: 50000 },
|
||||
{ name: "position", type: "number", required: true },
|
||||
{
|
||||
name: "priority",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["critical", "high", "medium", "low"],
|
||||
},
|
||||
{ name: "target_date", type: "date", required: false },
|
||||
{
|
||||
name: "created_by",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: usersId,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "created_by_role",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["owner", "client"],
|
||||
},
|
||||
{ name: "accepted", type: "bool", required: false },
|
||||
{ name: "start_date", type: "date", required: false },
|
||||
{ name: "scope_rule", type: "text", required: false, max: 60 },
|
||||
{ name: "archived", type: "bool", required: false },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE INDEX idx_cards_column ON cards (`column`, position)",
|
||||
"CREATE INDEX idx_cards_project ON cards (project, archived)",
|
||||
],
|
||||
});
|
||||
|
||||
// 5. subtasks
|
||||
const cardsId = (await pb.collections.getOne("cards")).id;
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "subtasks",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "card",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: cardsId,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
||||
{ name: "done", type: "bool", required: false },
|
||||
{ name: "position", type: "number", required: true },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE INDEX idx_subtasks_card ON subtasks (card, position)"],
|
||||
});
|
||||
|
||||
// 6. comments
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "comments",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "card",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: cardsId,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: usersId,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{ name: "body", type: "text", required: true, max: 20000 },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE INDEX idx_comments_card ON comments (card, created)"],
|
||||
});
|
||||
|
||||
// 7. activity
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "activity",
|
||||
type: "base",
|
||||
listRule: AUTHED,
|
||||
viewRule: AUTHED,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "project",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: projectsId,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: cardsId,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "actor",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: usersId,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
{
|
||||
name: "action",
|
||||
type: "select",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: [
|
||||
"card_created",
|
||||
"card_moved",
|
||||
"card_updated",
|
||||
"priority_changed",
|
||||
"date_changed",
|
||||
"card_accepted",
|
||||
"comment_added",
|
||||
"subtask_toggled",
|
||||
"card_archived",
|
||||
],
|
||||
},
|
||||
{ name: "field", type: "text", required: false, max: 60 },
|
||||
{ name: "old_value", type: "text", required: false, max: 2000 },
|
||||
{ name: "new_value", type: "text", required: false, max: 2000 },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE INDEX idx_activity_card ON activity (card, created)",
|
||||
"CREATE INDEX idx_activity_project ON activity (project, created)",
|
||||
],
|
||||
});
|
||||
|
||||
// 8. settings (superuser-only by design)
|
||||
await getOrCreateCollection(pb, {
|
||||
name: "settings",
|
||||
type: "base",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "ai_provider",
|
||||
type: "select",
|
||||
required: false,
|
||||
maxSelect: 1,
|
||||
values: ["anthropic", "openai", "minimax"],
|
||||
},
|
||||
{ name: "ai_api_key", type: "text", required: false, max: 400 },
|
||||
{ name: "ai_model", type: "text", required: false, max: 120 },
|
||||
{ name: "ai_system_prompt_extra", type: "text", required: false, max: 20000 },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
|
||||
console.log("Applying API rules...");
|
||||
|
||||
await applyRules(pb, "clients", {
|
||||
listRule: `${AUTHED} && (${OWNER} || id = @request.auth.client)`,
|
||||
viewRule: `${AUTHED} && (${OWNER} || id = @request.auth.client)`,
|
||||
createRule: OWNER,
|
||||
updateRule: OWNER,
|
||||
deleteRule: OWNER,
|
||||
});
|
||||
|
||||
await applyRules(pb, "projects", {
|
||||
listRule: scopedRead("client"),
|
||||
viewRule: scopedRead("client"),
|
||||
createRule: OWNER,
|
||||
updateRule: OWNER,
|
||||
deleteRule: OWNER,
|
||||
});
|
||||
|
||||
await applyRules(pb, "columns", {
|
||||
listRule: scopedRead("project.client"),
|
||||
viewRule: scopedRead("project.client"),
|
||||
createRule: OWNER,
|
||||
updateRule: OWNER,
|
||||
deleteRule: OWNER,
|
||||
});
|
||||
|
||||
await applyRules(pb, "cards", {
|
||||
listRule: scopedRead("project.client"),
|
||||
viewRule: scopedRead("project.client"),
|
||||
createRule: OWNER,
|
||||
updateRule: OWNER,
|
||||
deleteRule: OWNER,
|
||||
});
|
||||
|
||||
await applyRules(pb, "subtasks", {
|
||||
listRule: scopedRead("card.project.client"),
|
||||
viewRule: scopedRead("card.project.client"),
|
||||
createRule: OWNER,
|
||||
updateRule: OWNER,
|
||||
deleteRule: OWNER,
|
||||
});
|
||||
|
||||
await applyRules(pb, "comments", {
|
||||
listRule: scopedRead("card.project.client"),
|
||||
viewRule: scopedRead("card.project.client"),
|
||||
createRule: OWNER,
|
||||
updateRule: OWNER,
|
||||
deleteRule: OWNER,
|
||||
});
|
||||
|
||||
await applyRules(pb, "activity", {
|
||||
listRule: scopedRead("project.client"),
|
||||
viewRule: scopedRead("project.client"),
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
});
|
||||
|
||||
console.log("Done.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue