Adding a feature

Copy the todo resource through rbac, schema, contract, repo/service, RPC, routes, views, and i18n.

The todo feature is the reference. Add a new resource the same way, one layer at a time. Names below keep the @taman/ scope; substitute yours.

Permissions (packages/shared/rbac)

Add the resource to sharedStatements in rbac.shared.ts. Tuples must stay as const so better-auth infers action names:

export const sharedStatements = {
  todo: ['create', 'read', 'update', 'delete'],
  invoice: ['create', 'read', 'update', 'delete'],
} as const;

Grant those actions on the roles that should have them:

  • Platform: rbac.admin.ts (adminRole already grants todo: […]; userRole does not).
  • Organization: rbac.organizations.ts (owner and member both get full todo CRUD). The default organization admin role is dropped — roles replaces better-auth defaults.

Ownership ("this row is yours") is not expressed here. Check that in the service.

Database schema (packages/server/db-pg)

Add a table next to src/schemas/todo.schema.ts. Export it from src/schemas/index.ts.

Todos are org-scoped:

organizationId: uuid('organization_id')
  .notNull()
  .references(() => organizationTable.id, { onDelete: 'cascade' }),

Then pnpm db-pg:migrate:dev (drizzle-kit push in local dev).

Contract (packages/shared/api-contract)

Add src/<feature>/<feature>.schema.ts (Zod) and <feature>.contract.ts (oc.input / oc.output). Mount it on the root contract in src/index.ts:

export const contract = {
  menu: menuContract,
  todo: todoContract,
};

Typed errors belong on the contract (oc.errors({ NOT_FOUND: { message: '…' } })). Streams use eventIterator.

Repo and service (apps/api/server/domains/<feature>/)

  • Repo — drizzle queries only (todo.repo.ts). Fake repo + unit tests exist as the pattern (todo.repo.fake.ts).
  • Service — extends CoreService and defines its own private authorize(action) that requires a membership, checks this.can({ <feature>: [action] }), and returns member.organizationId (copy it from TodoService; CoreService does not provide it). Scope every query by that ID and map rows to contract types. Missing rows return null; do not throw NOT_FOUND here.

Publishing realtime events stays in the service (publisher.publish(todoChannel(orgId), event)).

RPC procedure (apps/api/server/rpc/procedures/)

const todo = os.todo.use(authed);

export const todoRouter = {
  get: todo.get.handler(async ({ context, input, errors }) => {
    const found = await (await todoService(context)).get(input.id);
    if (!found) {
      throw errors.NOT_FOUND();
    }
    return found;
  }),
  // list, create, update, remove, live
};

Register the router in server/rpc/router.ts. authed enforces a session, and an active organization for non-admins. Platform admins pass authed without one, so the service's authorize is what keeps their queries scoped.

Web route and view

  1. apps/web/src/router/routes/modules/<feature>.ts — default-export a RouteRecordRaw[]. Files in modules/*.ts (not nested) are eager-loaded. Set meta.icon, meta.order (ROUTE_ORDER in routes.constants.ts), meta.title via $t(...).
  2. Views under apps/web/src/views/<feature>/. generateAccess maps import.meta.glob('../views/**/*.vue').
  3. Call the API with orpc / client from src/api/orpc.ts.
import { useQuery } from '@tanstack/vue-query';
import { orpc } from '#/api/orpc';

const query = useQuery(orpc.todo.list.queryOptions({
  input: { page: 1, pageSize: 20 },
}));

i18n

Add JSON under apps/web/src/locales/langs/en-US/<feature>.json and id-ID/. Keys are loaded by loadLocalesMapFromDir. Use $t('todo.title') in route meta and views.

Checklist

  • sharedStatements + role grants
  • drizzle table, exported, pushed
  • contract schemas + procedures on the root contract
  • repo → service → os.<feature>.use(authed) handlers
  • router module + views
  • locale files
  • unit tests beside repo/service/procedure (todo has them)

What generate strips

The example gallery (apps/web/src/views/examples/** and routes/modules/dev/examples.ts) is not in a generated project. Copy patterns from todo, not from those demos, unless you are working in the source repo.