# Common Patterns

Quick recipes for common scenarios. Each pattern shows the complete `defineTable` configuration.

## Public Read, Authenticated Write

A job board where all team members can browse open positions but only hiring managers can create or edit jobs:

```typescript
// quickback/features/jobs/jobs.ts
export default defineTable(jobs, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["title", "department", "status", "salaryMin", "salaryMax"],
    updatable: ["title", "department", "status"],
  },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager"] } },
  update: { access: { roles: ["owner", "hiring-manager"] } },
  delete: { access: { roles: ["owner", "hiring-manager"] } },
});
```

## User-Scoped Personal Data

Interview notes that belong to a specific interviewer. Each interviewer only sees their own notes:

```typescript
// Schema includes both organizationId AND ownerId (q DSL)
import { q, defineTable } from "@quickback/compiler";

export const interviewNotes = q.table("interview_notes", {
  id:             q.id(),
  candidateId:    q.text().required(),
  content:        q.text().required(),
  rating:         q.int().optional(),
  organizationId: q.scope("organization"),
  ownerId:        q.text().required(),
});

export default defineTable(interviewNotes, {
  firewall: [
    { field: 'organizationId', equals: 'ctx.activeOrgId' },
    { field: 'ownerId',        equals: 'ctx.userId' },  // Only the interviewer can see their own notes
  ],
  guards: {
    createable: ["candidateId", "content", "rating"],
    updatable: ["content", "rating"],
  },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
  delete: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
});
```

The owner predicate (`{ field: 'ownerId', equals: 'ctx.userId' }`) means even hiring managers can only see notes they authored. To let hiring managers see all notes regardless of author, drop the owner predicate from the firewall and gate per-id access via record conditions instead.

## Multi-Table Feature with Internal Tables

A feature with a main table exposed via API and a junction table used only in actions:

```typescript
// quickback/features/applications/applications.ts — exposed via API
export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["candidateId", "jobId", "stage", "notes"],
    updatable: ["notes"],
  },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["owner", "hiring-manager"] } },
});
```

```typescript
// quickback/features/applications/interview-scores.ts — internal, no API routes
// No default export = no routes generated
export const interviewScores = sqliteTable("interview_scores", {
  applicationId: text("application_id").notNull(),
  interviewerId: text("interviewer_id").notNull(),
  score: integer("score").notNull(),
  feedback: text("feedback"),
  organizationId: text("organization_id").notNull(),  // Always scope junction tables
});
```

```typescript
// quickback/features/applications/actions.ts — uses the internal table
import { defineActions } from "@quickback/compiler";
import { applications } from "./applications";
import { interviewScores } from "./interview-scores";

export default defineActions(applications, {
  submitScore: {
    type: "record",
    input: z.object({ score: z.number().min(1).max(5), feedback: z.string() }),
    access: { roles: ["owner", "hiring-manager", "interviewer"] },
    execute: async ({ db, record, input, ctx }) => {
      await db.insert(interviewScores).values({
        applicationId: record.id,
        interviewerId: ctx.userId,
        score: input.score,
        feedback: input.feedback,
      });
      return record;
    },
  },
});
```

## Admin-Only Status Field

A field that only hiring managers can modify, but everyone can see:

```typescript
export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["candidateId", "jobId", "notes"],
    updatable: ["notes"],                // Recruiters can only update notes
    protected: ["stage"],                // Stage can only be changed via actions
  },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["hiring-manager"] } },
});
```

Then create an action that hiring managers use to change stage:

```typescript
// quickback/features/applications/actions.ts
export default defineActions(applications, {
  advanceStage: {
    type: "record",
    input: z.object({ stage: z.enum(["applied", "screening", "interview", "offer", "hired", "rejected"]) }),
    access: { roles: ["hiring-manager"] },
    execute: async ({ db, record, input }) => {
      await db.update(applications).set({ stage: input.stage }).where(eq(applications.id, record.id));
      return { ...record, stage: input.stage };
    },
  },
});
```

## Sensitive Data with Role-Based Masking

A candidate record where PII is masked differently per role:

```typescript
export default defineTable(candidates, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["name", "email", "phone", "resumeUrl", "source"],
    updatable: ["name", "phone"],
  },
  masking: {
    email: { type: "email", show: { roles: ["hiring-manager", "recruiter"] } },
    phone: { type: "phone", show: { roles: ["hiring-manager", "recruiter"] } },
  },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["hiring-manager", "recruiter"] } },
  update: { access: { roles: ["hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["hiring-manager"] } },
});
```

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

## External System Sync

An import table for syncing candidates from an external ATS via upsert:

```typescript
// quickback/features/ats-imports/ats-imports.ts
export default defineTable(atsImports, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["externalId", "source", "candidateName", "candidateEmail", "rawPayload"],
    updatable: ["candidateName", "candidateEmail", "rawPayload", "syncedAt"],
  },
  read: {
    access: { roles: ["owner", "hiring-manager"] },
  },
  create: { access: { roles: ["owner", "hiring-manager"] } },
  update: { access: { roles: ["owner", "hiring-manager"] } },
  delete: { access: { roles: ["owner"] } },
});
```

## See Also

- [Full Example](/compiler/getting-started/full-example) — Complete walkthrough with generated code
- [Firewall](/compiler/definitions/firewall) — All data isolation options
- [Actions](/compiler/definitions/actions) — Custom business logic
