Common Patterns
Recipes for common Quickback scenarios — public/private data, user-scoped resources, multi-table features, and more.
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:
// quickback/features/jobs/jobs.ts
export default defineTable(jobs, {
firewall: { organization: {} },
guards: {
createable: ["title", "department", "status", "salaryMin", "salaryMax"],
updatable: ["title", "department", "status"],
},
crud: {
list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
get: { 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:
// Schema includes both organizationId AND ownerId
export const interviewNotes = sqliteTable("interview_notes", {
id: text("id").primaryKey(),
organizationId: text("organization_id").notNull(),
ownerId: text("owner_id").notNull(),
candidateId: text("candidate_id").notNull(),
content: text("content").notNull(),
rating: integer("rating"),
});
export default defineTable(interviewNotes, {
firewall: {
organization: {},
owner: { mode: "strict" }, // Only the interviewer can see their own notes
},
guards: {
createable: ["candidateId", "content", "rating"],
updatable: ["content", "rating"],
},
crud: {
list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
get: { 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"] } },
},
});With owner.mode: "strict", even hiring managers can only see their own notes. Use "optional" to let hiring managers see all notes.
Multi-Table Feature with Internal Tables
A feature with a main table exposed via API and a junction table used only in actions:
// quickback/features/applications/applications.ts — exposed via API
export default defineTable(applications, {
firewall: { organization: {} },
guards: {
createable: ["candidateId", "jobId", "stage", "notes"],
updatable: ["notes"],
},
crud: {
list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
get: { 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"] } },
},
});// 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
});// 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:
export default defineTable(applications, {
firewall: { organization: {} },
guards: {
createable: ["candidateId", "jobId", "notes"],
updatable: ["notes"], // Recruiters can only update notes
protected: ["stage"], // Stage can only be changed via actions
},
crud: {
list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
get: { 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:
// 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:
export default defineTable(candidates, {
firewall: { organization: {} },
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"] } },
},
crud: {
list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
get: { 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 | 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 PUT/upsert:
// quickback/features/ats-imports/ats-imports.ts
export default defineTable(atsImports, {
firewall: { organization: {} },
guards: {
createable: ["externalId", "source", "candidateName", "candidateEmail", "rawPayload"],
updatable: ["candidateName", "candidateEmail", "rawPayload", "syncedAt"],
},
crud: {
list: { access: { roles: ["owner", "hiring-manager"] } },
get: { 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 — Complete walkthrough with generated code
- Firewall — All data isolation options
- Actions — Custom business logic