# Complete Example

This example shows a complete resource definition using all five security layers, and the production code Quickback generates from it.

## What You Define

A `candidates` resource for a multi-tenant recruitment application with:
- Organization-level data isolation
- Role-based write permissions
- Field-level write protection
- PII masking for sensitive fields
- Column-level views for different access levels

```typescript title="quickback/features/candidates/candidates.ts"
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    phone:          q.text().optional(),
    resumeUrl:      q.text().optional(),
    source:         q.text().optional(),  // e.g. "linkedin", "referral", "careers-page"
    internalNotes:  q.text().optional(),
    organizationId: q.scope("organization"),
    // Compiler-managed columns are declared, not injected behind your back:
    // `...q.audit()` expands to createdAt / modifiedAt / createdBy / modifiedBy,
    // `...q.softDelete()` to deletedAt / deletedBy. The audit quartet is
    // required on every table; the soft-delete pair is required because this
    // resource soft-deletes (`delete.mode: 'soft'`).
    ...q.audit(),
    ...q.softDelete(),
  },

  // firewall — auto-derived from q.scope("organization") + the declared
  // deletedAt; explicit declaration is optional once a scope column is present.

  // GUARDS — Field-level protection
  guards: {
    createable: ['name', 'email', 'phone', 'resumeUrl', 'source'],
    updatable:  ['name', 'phone'],
  },

  // MASKING — PII redaction (query.roles defaults to show.roles)
  masking: {
    email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },
    phone: { type: 'phone', show: { roles: ['hiring-manager', 'recruiter'] } },
  },

  // READ — Collection + per-id GET, plus named views
  read: {
    access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
    views: {
      pipeline: {
        fields: ['id', 'name', 'source'],
        access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
      },
      full: {
        fields: ['id', 'name', 'email', 'phone', 'resumeUrl', 'source', 'internalNotes'],
        access: { roles: ['hiring-manager', 'recruiter'] },
      },
    },
  },

  // WRITE OPERATIONS
  create: { access: { roles: ['hiring-manager', 'recruiter'] } },
  update: { access: { roles: ['hiring-manager', 'recruiter'] } },
  delete: { access: { roles: ['hiring-manager'] }, mode: 'soft' },
});
```

> `mode: 'hard'` is also available, but hard deletes erase the row — so they require the security-audit pipeline. On D1 the compiler emits an `AUDIT_DB` binding (placeholder id until `quickback deploy` creates the database). A table only drops `...q.softDelete()` when **both** `delete.mode` and `batchDelete.mode` are `'hard'`; declaring the pair on a hard-delete table is itself a compile error.

> Prefer the explicit two-export form (or interop with existing Drizzle schemas)? See [feature() — single-export sugar](/define/feature) and [Schema: choosing between `q` and Drizzle](/define/schema#choosing-between-q-and-drizzle). All forms compile identically; mix freely per file.

---

## What Quickback Generates

Run `quickback compile` and Quickback generates production-ready code.

### Security Helpers

```typescript title="src/features/candidates/candidates.resource.ts"
import { eq, and, isNull } from 'drizzle-orm';
import { candidates } from './schema';
import type { AppContext } from '../../lib/types';
import { masks } from '../../lib/masks';

/**
 * Firewall conditions for candidates
 * Auto-derived from q.scope("organization") + the declared deletedAt.
 * 2 predicates: tenant scope AND soft-delete filter.
 */
export function buildFirewallConditions(ctx: AppContext) {
  return and(
    eq(candidates.organizationId, ctx.activeOrgId!),
    isNull(candidates.deletedAt),
  );
}

/**
 * Guards configuration for field modification rules.
 * organizationId is added to systemManaged because q.scope("organization")
 * is bound to ctx.activeOrgId — clients cannot submit it.
 */
export const GUARDS_CONFIG = {
  createable: new Set(['name', 'email', 'phone', 'resumeUrl', 'source']),
  updatable: new Set(['name', 'phone']),
  immutable: new Set([]),
  protected: {},
  systemManaged: new Set([
    'createdAt', 'createdBy',
    'modifiedAt', 'modifiedBy',
    'deletedAt', 'deletedBy',
    'organizationId',  // ← added by q.scope("organization")
  ]),
};

/**
 * Validates field input for CREATE operations.
 */
export function validateCreate(input: Record<string, any>) {
  const systemManaged: string[] = [];
  const protectedFields: Array<{ field: string; actions: string[] }> = [];
  const notCreateable: string[] = [];

  for (const field of Object.keys(input)) {
    if (GUARDS_CONFIG.systemManaged.has(field)) {
      systemManaged.push(field);
      continue;
    }
    if (field in GUARDS_CONFIG.protected) {
      const actions = [...GUARDS_CONFIG.protected[field]];
      protectedFields.push({ field, actions });
      continue;
    }
    if (!GUARDS_CONFIG.createable.has(field) && !GUARDS_CONFIG.immutable.has(field)) {
      notCreateable.push(field);
    }
  }

  const valid = systemManaged.length === 0 &&
                protectedFields.length === 0 &&
                notCreateable.length === 0;

  return { valid, systemManaged, protected: protectedFields, notCreateable };
}

/**
 * Validates field input for UPDATE operations.
 */
export function validateUpdate(input: Record<string, any>) {
  const systemManaged: string[] = [];
  const immutable: string[] = [];
  const protectedFields: Array<{ field: string; actions: string[] }> = [];
  const notUpdatable: string[] = [];

  for (const field of Object.keys(input)) {
    if (GUARDS_CONFIG.systemManaged.has(field)) {
      systemManaged.push(field);
      continue;
    }
    if (GUARDS_CONFIG.immutable.has(field)) {
      immutable.push(field);
      continue;
    }
    if (field in GUARDS_CONFIG.protected) {
      const actions = [...GUARDS_CONFIG.protected[field]];
      protectedFields.push({ field, actions });
      continue;
    }
    if (!GUARDS_CONFIG.updatable.has(field)) {
      notUpdatable.push(field);
    }
  }

  const valid = systemManaged.length === 0 &&
                immutable.length === 0 &&
                protectedFields.length === 0 &&
                notUpdatable.length === 0;

  return { valid, systemManaged, immutable, protected: protectedFields, notUpdatable };
}

/**
 * Masks sensitive fields based on user role
 */
export function maskCandidate<T extends Record<string, any>>(
  record: T,
  ctx: AppContext
): T {
  const masked: any = { ...record };

  // email: show to hiring-manager, recruiter
  if (!ctx.roles?.some(r => ['hiring-manager', 'recruiter'].includes(r))) {
    if (masked['email'] != null) {
      masked['email'] = masks.email(masked['email']);
    }
  }

  // phone: show to hiring-manager, recruiter
  if (!ctx.roles?.some(r => ['hiring-manager', 'recruiter'].includes(r))) {
    if (masked['phone'] != null) {
      masked['phone'] = masks.phone(masked['phone']);
    }
  }

  return masked as T;
}

export function maskCandidates<T extends Record<string, any>>(
  records: T[],
  ctx: AppContext
): T[] {
  return records.map(r => maskCandidate(r, ctx));
}

// Write access configuration (internal normalized shape)
export const CRUD_ACCESS = {
  create: { access: { roles: ['hiring-manager', 'recruiter'] } },
  update: { access: { roles: ['hiring-manager', 'recruiter'] } },
  delete: { access: { roles: ['hiring-manager'] }, mode: 'soft' },
};

// Views configuration (read pipeline)
export const VIEWS_CONFIG = {
  pipeline: {
    fields: ['id', 'name', 'source'],
    access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
  },
  full: {
    fields: ['id', 'name', 'email', 'phone', 'resumeUrl', 'source', 'internalNotes'],
    access: { roles: ['hiring-manager', 'recruiter'] },
  },
} as const;
```

### API Routes

The generated routes wire everything together:

```typescript title="src/features/candidates/candidates.routes.ts"
import { Hono } from 'hono';
import { eq, and } from 'drizzle-orm';
import { candidates } from './schema';
import {
  buildFirewallConditions,
  validateCreate,
  validateUpdate,
  maskCandidate,
  maskCandidates,
  VIEWS_CONFIG,
  CRUD_ACCESS,
} from './candidates.resource';
import { evaluateAccess } from '../../lib/access';
import { AuthErrors, AccessErrors, GuardErrors } from '../../lib/errors';

const app = new Hono();

// GET /candidates/views/:viewName — named view projection
const READ_ACCESS = { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] };

app.get('/views/:viewName', async (c) => {
  const ctx = c.get('ctx');
  const db = c.get('db');

  const viewName = c.req.param('viewName');
  const params = Object.fromEntries(new URL(c.req.url).searchParams);
  const view = VIEWS_CONFIG[viewName as keyof typeof VIEWS_CONFIG];
  if (!view) return c.json({ error: 'Unknown view', code: 'VIEW_NOT_FOUND' }, 400);

  // Per-view access gate (falls back to read-level access)
  if (!await evaluateAccess(view.access ?? READ_ACCESS, ctx)) {
    return c.json(AccessErrors.roleRequired(view.access?.roles ?? READ_ACCESS.roles, ctx.roles), 403);
  }

  // Query with firewall conditions, project view fields, mask, return.
  const results = await db.select(/* projected fields */).from(candidates)
    .where(buildFirewallConditions(ctx));

  return c.json({ data: maskCandidates(results, ctx), view: viewName });
});

// POST /candidates - Create with guards
app.post('/', async (c) => {
  const ctx = c.get('ctx');
  const db = c.get('db');

  // Access check
  if (!await evaluateAccess(CRUD_ACCESS.create.access, ctx)) {
    return c.json(AccessErrors.roleRequired(
      CRUD_ACCESS.create.access.roles,
      ctx.roles
    ), 403);
  }

  const body = await c.req.json();

  // Guards validation
  const validation = validateCreate(body);
  if (!validation.valid) {
    if (validation.systemManaged.length > 0) {
      return c.json(GuardErrors.systemManaged(validation.systemManaged), 400);
    }
    if (validation.notCreateable.length > 0) {
      return c.json(GuardErrors.fieldNotCreateable(validation.notCreateable), 400);
    }
  }

  // Apply ownership and audit fields
  const data = {
    id: 'cnd_' + crypto.randomUUID().replace(/-/g, ''),
    ...body,
    organizationId: ctx.activeOrgId,
    createdAt: new Date().toISOString(),
    createdBy: ctx.userId,
    modifiedAt: new Date().toISOString(),
    modifiedBy: ctx.userId,
  };

  const result = await db.insert(candidates).values(data).returning();
  return c.json(maskCandidate(result[0], ctx), 201);
});

// PATCH /candidates/:id - Update with guards
app.patch('/:id', async (c) => {
  const ctx = c.get('ctx');
  const db = c.get('db');
  const id = c.req.param('id');

  // Fetch with firewall
  const [record] = await db.select().from(candidates)
    .where(and(buildFirewallConditions(ctx), eq(candidates.id, id)));

  if (!record) {
    return c.json({ error: 'Not found', code: 'NOT_FOUND' }, 404);
  }

  // Access check
  if (!await evaluateAccess(CRUD_ACCESS.update.access, ctx)) {
    return c.json(AccessErrors.roleRequired(
      CRUD_ACCESS.update.access.roles,
      ctx.roles
    ), 403);
  }

  const body = await c.req.json();

  // Guards validation
  const validation = validateUpdate(body);
  if (!validation.valid) {
    if (validation.notUpdatable.length > 0) {
      return c.json(GuardErrors.fieldNotUpdatable(validation.notUpdatable), 400);
    }
  }

  // Apply audit fields
  const data = {
    ...body,
    modifiedAt: new Date().toISOString(),
    modifiedBy: ctx.userId,
  };

  const result = await db.update(candidates).set(data)
    .where(eq(candidates.id, id)).returning();

  return c.json(maskCandidate(result[0], ctx));
});

export default app;
```

---

## Runtime Behavior

### Masking by Role

| Role | `email` | `phone` |
|------|---------|---------|
| **hiring-manager** | `jane@example.com` | `555-123-4567` |
| **recruiter** | `jane@example.com` | `555-123-4567` |
| **interviewer** | `j***@e******.com` | `******4567` |

### Views + Masking Interaction

Views control **which fields are returned**. Masking controls **what values are shown**.

| Endpoint | Role | `phone` in response? | `phone` value |
|----------|------|--------------------|-------------|
| `/candidates/views/pipeline` | interviewer | No | N/A |
| `/candidates/views/pipeline` | recruiter | No | N/A |
| `/candidates/views/full` | interviewer | No | N/A (no access) |
| `/candidates/views/full` | recruiter | Yes | `555-123-4567` |

### Guards Enforcement

```bash
# Allowed: Create with createable fields
POST /candidates
{ "name": "Jane Smith", "email": "jane@example.com", "source": "linkedin" }

# Rejected: Update non-updatable field
PATCH /candidates/cnd_123
{ "email": "new@example.com" }
# -> 400: "Field 'email' is not updatable"

# Rejected: Set system-managed field
POST /candidates
{ "name": "Jane Smith", "createdBy": "hacker" }
# -> 400: "System-managed fields cannot be set: createdBy"
```

---

## Generated API Endpoints

| Method | Endpoint | Access |
|--------|----------|--------|
| `GET` | `/candidates` | owner, hiring-manager, recruiter, interviewer |
| `GET` | `/candidates/:id` | owner, hiring-manager, recruiter, interviewer |
| `POST` | `/candidates` | hiring-manager, recruiter |
| `PATCH` | `/candidates/:id` | hiring-manager, recruiter |
| `DELETE` | `/candidates/:id` | hiring-manager |
| `GET` | `/candidates/views/pipeline` | owner, hiring-manager, recruiter, interviewer |
| `GET` | `/candidates/views/full` | hiring-manager, recruiter |

---

## Key Takeaways

1. **~50 lines of configuration** generates **~500+ lines of production code**
2. **Security is declarative** - you describe *what* you want, not *how*
3. **All layers work together** - Firewall -> Access -> Guards -> Masking
4. **Code is readable and auditable** - no magic, just TypeScript
5. **Type-safe from definition to API** - Drizzle schema drives everything

## Keep going in the same file

Everything above is one config object, and it takes more keys than security and
CRUD. Each adds a real endpoint on the same pipeline, so reach for these before
writing a custom action:

- [`transitions`](/define/transitions) — a guarded state change (`publish`,
  `approve`, `checkIn`) generated as `POST /candidates/:id/{name}`
- [`realtime`](/platform/realtime/using-realtime) — broadcast inserts, updates,
  and deletes to subscribed clients
- [`triggers`](/define/triggers) — run a data rule or side-effect on every write
- [`owns`](/define/changesets) — write a parent row and its children atomically

The `views` you already declared are the same idea: a whole endpoint from a few
lines. See [the full option list](/define#other-resource-options).
