| name | feature-implementation |
| description | Use when: implementing new features, adding CRUD functionality, creating new pages/routes/components, adding database tables, building new API endpoints, adding worker processors, extending the platform with new capabilities, or when asked to 'add a feature', 'create a new module', or 'build X'. Covers end-to-end implementation across Next.js App Router, NestJS worker, Drizzle ORM, BullMQ queues, React components, RBAC permissions, and all SuperCheck architectural conventions. |
SuperCheck Feature Implementation
Implementation Workflow
Step 0: Understand the Feature Scope
Before writing any code, classify the feature:
| Scope | Description | Layers Involved |
|---|
| UI-only | New component, page, or visual change | Components, Pages |
| App CRUD | New entity with create/read/update/delete | Schema, Migration, API Routes, Server Actions, Components, Pages, Hooks, RBAC, Tests |
| App + Worker | Feature that triggers background processing | All of App CRUD + Worker Module, Processor, Service, Queue Constants |
| API-only | New endpoint for CLI/external consumption | Schema, API Route, Validation, Auth, Tests |
| Worker-only | New background processor or execution type | Worker Module, Processor, Service, Constants, Tests |
Step 1: Database Schema
File: app/src/db/schema/{feature}.ts
import { pgTable, uuid, varchar, text, timestamp, boolean, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { organization, projects } from "./organization";
import { user } from "./auth";
export const features = pgTable("features", {
id: uuid("id").primaryKey().$defaultFn(() => sql`uuidv7()`),
organizationId: uuid("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
projectId: uuid("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
createdByUserId: uuid("created_by_user_id")
.references(() => user.id, { onDelete: "no action" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
status: varchar("status", { length: 50 }).$type<FeatureStatus>().notNull().default("active"),
config: jsonb("config").$type<FeatureConfig>(),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at"),
}, (table) => ({
projectOrgIdx: index("features_project_org_idx").on(table.projectId, table.organizationId),
uniqueNameIdx: uniqueIndex("features_project_name_idx").on(table.projectId, table.name),
statusIdx: index("features_status_idx").on(table.projectId, table.organizationId, table.status),
}));
export const featureResults = pgTable("feature_results", {
id: uuid("id").primaryKey().$defaultFn(() => sql`uuidv7()`),
featureId: uuid("feature_id")
.notNull()
.references(() => features.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (table) => ({
featureIdx: index("feature_results_feature_idx").on(table.featureId),
}));
export const insertFeatureSchema = createInsertSchema(features);
export const selectFeatureSchema = createSelectSchema(features);
export type Feature = typeof features.$inferSelect;
export type NewFeature = typeof features.$inferInsert;
Checklist:
Export from index:
export * from "./feature";
Step 2: Generate Migration
cd app
npm run db:generate
npm run db:migrate
Migration checklist:
Step 3: Input Validation Schemas
File: app/src/lib/validations/{feature}.ts
import { z } from "zod";
export const createFeatureSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(255, "Name must be 255 characters or less")
.trim(),
description: z.string().max(2000).optional(),
config: z.object({
timeout: z.number().min(1).max(300).default(30),
retries: z.number().min(0).max(5).default(0),
}).optional(),
enabled: z.boolean().default(true),
});
export const updateFeatureSchema = createFeatureSchema.partial().extend({
id: z.().(),
});
= z.< createFeatureSchema>;
= z.< updateFeatureSchema>;
Conventions:
- Centralize in
app/src/lib/validations/
- Include human-readable error messages
- Use
.trim() on string fields
- Export inferred types
- Create partial schema for updates
- Use
.uuid() for ID validation
Step 4: RBAC Permissions
File: app/src/lib/rbac/permissions-client.ts (add resource)
export const statements = {
feature: ["create", "update", "delete", "view"] as const,
} as const;
File: app/src/lib/rbac/permissions.ts (add role mappings)
const rolePermissions = {
[Role.ORG_OWNER]: {
feature: ["create", "update", "delete", "view"],
},
[Role.ORG_ADMIN]: {
feature: ["create", "update", "delete", "view"],
},
[Role.PROJECT_ADMIN]: {
feature: ["create", "update", "delete", "view"],
},
[Role.PROJECT_EDITOR]: {
feature: ["create", "update", "view"],
},
[Role.PROJECT_VIEWER]: {
feature: ["view"],
},
};
Checklist:
Step 5: Server Actions
File: app/src/actions/create-{feature}.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/utils/db";
import { features } from "@/db/schema";
import { requireProjectContext } from "@/lib/project-context";
import { checkPermissionWithContext } from "@/lib/rbac/middleware";
import { logAuditEvent } from "@/lib/audit-logger";
import { createFeatureSchema, type CreateFeatureData } from "@/lib/validations/feature";
type CreateFeatureResult = {
success: boolean;
data?: { id: string };
message?: string;
error?: string;
};
export async function createFeature(input: CreateFeatureData): Promise<CreateFeatureResult> {
console.log(`[CREATE_FEATURE] Starting...`);
try {
{ userId, project, organizationId } = ();
canCreate = (, , {
userId, organizationId, project,
});
(!canCreate) {
.();
{ : , : };
}
validated = createFeatureSchema.(input);
[feature] = db
.(features)
.({
organizationId,
: project.,
: userId,
: validated.,
: validated.,
: validated.,
: validated.,
})
.({ : features. });
({
userId,
: ,
: ,
: feature.,
: { organizationId, : project., : validated. },
: ,
});
();
.();
{ : , : { : feature. }, : };
} (error) {
.(, error);
{ : , : };
}
}
File: app/src/actions/update-{feature}.ts
"use server";
import { eq, and } from "drizzle-orm";
export async function updateFeature(input: UpdateFeatureData): Promise<UpdateFeatureResult> {
try {
const { userId, project, organizationId } = await requireProjectContext();
const canUpdate = checkPermissionWithContext("feature", "update", {
userId, organizationId, project,
});
if (!canUpdate) {
return { success: false, error: "Insufficient permissions" };
}
const validated = updateFeatureSchema.parse(input);
const [existing] = await db
.select({ id: features.id })
.from(features)
.where(
and(
eq(features.id, validated.id),
eq(features.projectId, project.id),
eq(features.organizationId, organizationId)
)
)
.limit();
(!existing) {
{ : , : };
}
db
.(features)
.({
: validated.,
: validated.,
: validated.,
: (),
})
.((features., validated.));
({
userId,
: ,
: ,
: validated.,
: { organizationId },
: ,
});
();
();
{ : , : };
} (error) {
.(, error);
{ : , : };
}
}
File: app/src/actions/delete-{feature}.ts
"use server";
const uuidSchema = z.string().uuid("Invalid feature ID");
export async function deleteFeature(id: string): Promise<DeleteFeatureResult> {
try {
const parseResult = uuidSchema.safeParse(id);
if (!parseResult.success) {
return { success: false, error: "Invalid feature ID" };
}
const { userId, project, organizationId } = await requireProjectContext();
const canDelete = checkPermissionWithContext("feature", "delete", {
userId, organizationId, project,
});
if (!canDelete) {
return { success: false, error: "Insufficient permissions" };
}
const [existing] = await db
.select({ id: features.id, name: features.name })
.from(features)
.where(
and(
(features., id),
(features., project.),
(features., organizationId)
)
)
.();
(!existing) {
{ : , : };
}
db.( (tx) => {
tx.(features).((features., id));
});
({
userId,
: ,
: ,
: id,
: { organizationId, : existing. },
: ,
});
();
{ : , : };
} (error) {
.(, error);
{ : , : };
}
}
Server Action Conventions:
Step 6: API Routes (for CLI / external access)
File: app/src/app/api/{feature}/route.ts (list + create)
import { NextRequest, NextResponse } from "next/server";
import { requireAuthContext, isAuthError } from "@/lib/auth-context";
import { checkPermissionWithContext } from "@/lib/rbac/middleware";
import { db } from "@/utils/db";
import { features } from "@/db/schema";
import { eq, and, desc, sql } from "drizzle-orm";
import { createFeatureSchema } from "@/lib/validations/feature";
export async function GET(request: NextRequest) {
try {
const context = await requireAuthContext();
const canView = checkPermissionWithContext("feature", "view", context);
if (!canView) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const url = new (request.);
page = .(, (url..() || , ));
limit = .((url..() || , ), );
whereCondition = (
(features., context..),
(features., context.)
);
[countResult, data] = .([
db.({ : sql<> }).(features).(whereCondition),
db.().(features).(whereCondition)
.((features.))
.(limit)
.((page - ) * limit),
]);
total = (countResult[]?. || );
.({
: ,
data,
: { page, limit, total, : .(total / limit) },
});
} (error) {
((error)) {
.({ : }, { : });
}
.(, error);
.({ : }, { : });
}
}
() {
{
context = ();
canCreate = (, , context);
(!canCreate) {
.({ : }, { : });
}
body = createFeatureSchema.( request.());
[feature] = db
.(features)
.({
: context.,
: context..,
: context.,
...body,
})
.();
.({ : , : feature }, { : });
} (error) {
((error)) {
.({ : }, { : });
}
.(, error);
.({ : }, { : });
}
}
File: app/src/app/api/{feature}/[id]/route.ts (get, update, delete)
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const idSchema = z.string().uuid();
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const context = await requireAuthContext();
const { id } = await params;
const parseResult = idSchema.safeParse(id);
if (!parseResult.success) {
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
}
const canView = checkPermissionWithContext("feature", "view", context);
if (!canView) {
return NextResponse.json({ error: "Insufficient permissions" }, { : });
}
[feature] = db
.()
.(features)
.(
(
(features., id),
(features., context..),
(features., context.)
)
)
.();
(!feature) {
.({ : }, { : });
}
.({ : , : feature });
} (error) {
((error)) {
.({ : }, { : });
}
.({ : }, { : });
}
}
() {
{
context = ();
{ id } = params;
canUpdate = (, , context);
(!canUpdate) {
.({ : }, { : });
}
body = updateFeatureSchema.( request.());
[existing] = db
.({ : features. })
.(features)
.(
(
(features., id),
(features., context..),
(features., context.)
)
)
.();
(!existing) {
.({ : }, { : });
}
[updated] = db
.(features)
.({ ...body, : () })
.((features., id))
.();
.({ : , : updated });
} (error) {
((error)) {
.({ : }, { : });
}
.({ : }, { : });
}
}
() {
{
context = ();
{ id } = params;
canDelete = (, , context);
(!canDelete) {
.({ : }, { : });
}
[existing] = db
.({ : features. })
.(features)
.(
(
(features., id),
(features., context..),
(features., context.)
)
)
.();
(!existing) {
.({ : }, { : });
}
db.(features).((features., id));
.({ : , : });
} (error) {
((error)) {
.({ : }, { : });
}
.({ : }, { : });
}
}
API Route Conventions:
Step 7: React Query Hook
File: app/src/hooks/use-{feature}s.ts
import { createDataHook } from "./lib/create-data-hook";
import type { Feature } from "@/db/schema";
import type { CreateFeatureData, UpdateFeatureData } from "@/lib/validations/feature";
export const FEATURES_QUERY_KEY = ["features"] as const;
const featuresHook = createDataHook<Feature, CreateFeatureData, UpdateFeatureData>({
queryKey: FEATURES_QUERY_KEY,
endpoint: "/api/features",
staleTime: 30 * 1000,
});
export function useFeatures() {
const { data, isLoading, isRestoring, invalidate, error } = featuresHook.useQuery({});
return {
features: data?.data || [],
pagination: data?.pagination,
isLoading,
isRestoring,
invalidate,
error,
};
}
export function useFeature(id: string) {
const { data, isLoading, error } = featuresHook.(id);
{
: data?. || ,
isLoading,
error,
};
}
Hook Conventions:
Step 8: React Components
Component Directory Structure
app/src/components/{feature}/
├── index.tsx # Main list/manager component
├── {feature}-dialog.tsx # Create/edit dialog
├── data-table.tsx # Data table wrapper
├── columns.tsx # Table column definitions
├── schema.ts # Client-side Zod schema (for table filtering)
└── {feature}-detail.tsx # Detail view (if needed)
Columns Definition
File: app/src/components/{feature}/columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "@/components/ui/data-table-column-header";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import type { Feature } from "@/db/schema";
export const columns: ColumnDef<Feature>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader = = />,
: {
status = row.() ;
(
);
},
: value.(row.(id)),
},
{
: ,
: ,
: {
date = (row.());
;
},
},
];
Data Table
File: app/src/components/{feature}/data-table.tsx
"use client";
import { useState } from "react";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
type ColumnFiltersState,
} from "@tanstack/react-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
isLoading?: boolean;
onRowClick?: (row: Row<TData>) => void;
meta?: { onDelete?: (id: string) => void };
}
export function DataTable<TData, TValue>({
columns, data, isLoading, onRowClick, meta,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [pagination, setPagination] = ({ : , : });
table = ({
data,
columns,
: (),
: (),
: (),
: (),
: { sorting, columnFilters, pagination },
: setSorting,
: setColumnFilters,
: setPagination,
meta,
});
(isLoading) ;
(
);
}
Create/Edit Dialog
File: app/src/components/{feature}/{feature}-dialog.tsx
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { createFeatureSchema, type CreateFeatureData } from "@/lib/validations/feature";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface FeatureDialogProps {
open: boolean;
onOpenChange: (open: ) => ;
?: | ;
: ;
}
() {
[loading, setLoading] = ();
isEditing = !!feature;
form = useForm<>({
: (createFeatureSchema),
: {
: feature?. || ,
: feature?. || ,
: feature?. ?? ,
},
});
= () => {
();
{
result = isEditing
? ({ : feature., ...data })
: (data);
(result.) {
toast.(isEditing ? : );
();
();
form.();
} {
toast.(result. || );
}
} {
toast.();
} {
();
}
};
(
);
}
Main List Component
File: app/src/components/{feature}/index.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useFeatures } from "@/hooks/use-features";
import { deleteFeature } from "@/actions/delete-feature";
import { DataTable } from "./data-table";
import { columns } from "./columns";
import { FeatureDialog } from "./feature-dialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
export default function FeaturesList() {
const router = useRouter();
const { features, isLoading, isRestoring, invalidate } = useFeatures();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingFeature, setEditingFeature] = useState<Feature | null>();
= () => {
router.();
};
= () => {
result = (id);
(result.) {
toast.();
();
} {
toast.(result. || );
}
};
= () => {
();
};
(!isRestoring) ;
(
);
}
Component Conventions:
Step 9: Pages
List Page
File: app/src/app/(main)/{feature}/page.tsx
import FeaturesList from "@/components/features";
import { PageBreadcrumbs } from "@/components/page-breadcrumbs";
import { Card, CardContent } from "@/components/ui/card";
export default function FeaturesPage() {
return (
<div>
<PageBreadcrumbs
items={[
{ label: "Home", href: "/" },
{ label: "Features", isCurrentPage: true },
]}
/>
<Card>
<CardContent>
<FeaturesList />
</CardContent>
</Card>
</div>
);
}
Detail Page (Server Component with data fetching)
File: app/src/app/(main)/{feature}/[id]/page.tsx
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { db } from "@/utils/db";
import { features } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { requireProjectContext } from "@/lib/project-context";
import { FeatureDetailClient } from "@/components/features/feature-detail-client";
async function getFeature(id: string) {
const { project, organizationId } = await requireProjectContext();
const [feature] = await db
.select()
.from(features)
.where(
and(
eq(features.id, id),
eq(features.projectId, project.id),
eq(features.organizationId, organizationId)
)
)
.limit(1);
return feature || ;
}
(): <> {
{ id } = params;
feature = (id);
{ : feature?. || };
}
() {
{ id } = params;
feature = (id);
(!feature) ();
;
}
Loading Page
File: app/src/app/(main)/{feature}/loading.tsx
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton";
export default function Loading() {
return <DataTableSkeleton columns={4} rows={5} />;
}
Step 10: Navigation
File: app/src/components/nav-main.tsx (add nav item)
Add the new feature to the navigation items array:
{
title: "Features",
url: "/features",
icon: IconComponent,
}
Step 11: Self-Hosted / Cloud Feature Gating
File: app/src/lib/feature-flags.ts
export function isFeatureEnabled(): boolean {
return isSelfHosted() || hasActiveSubscription();
}
export function getFeatureLimit(plan: string): number {
if (isSelfHosted()) return Infinity;
switch (plan) {
case "plus": return 10;
case "pro": return 50;
default: return 3;
}
}
Self-hosted conventions:
SELF_HOSTED is "true" or "1" (string comparison)
- Self-hosted mode: no billing, no email verification, no CAPTCHA, unlimited limits
- New features should work in both modes unless explicitly scoped
Step 12: Plan Limit Enforcement (Cloud)
If the feature has plan-based limits:
File: app/src/lib/middleware/plan-enforcement.ts (add check function)
export async function checkFeatureLimit(
organizationId: string,
currentCount: number
): Promise<{ allowed: boolean; error?: string }> {
if (isSelfHosted()) return { allowed: true };
const plan = await getOrganizationPlan(organizationId);
const limit = getFeatureLimit(plan);
if (currentCount >= limit) {
return {
allowed: false,
error: `Feature limit reached (${limit}). Upgrade your plan.`,
};
}
return { allowed: true };
}
Call this in both server actions and API routes before creating new resources.
Worker Integration (for features that need background processing)
Step W1: Queue Constants
File: app/src/lib/queue.ts (add queue name)
export const FEATURE_QUEUE = "feature-global";
export function featureQueueName(locationCode: string): string {
return `feature-${locationCode}`;
}
Synchronize the same constants in:
worker/src/{feature}/{feature}.constants.ts
export const FEATURE_QUEUE = "feature-global";
Step W2: Job DTO
File: worker/src/{feature}/dto/{feature}-job.dto.ts
export class FeatureJobDto {
featureId: string;
projectId: string;
organizationId: string;
config: FeatureConfig;
variables?: Record<string, string>;
secrets?: Record<string, string>;
}
Step W3: Worker Module
File: worker/src/{feature}/{feature}.module.ts
import { Module, DynamicModule, Logger } from "@nestjs/common";
import { BullModule } from "@nestjs/bullmq";
import { DbModule } from "../db/db.module";
import { FEATURE_QUEUE } from "./{feature}.constants";
import { FeatureService } from "./{feature}.service";
import { FeatureProcessor } from "./processors/{feature}.processor";
@Module({})
export class FeatureModule {
private static readonly logger = new Logger("FeatureModule");
static forRoot(): DynamicModule {
const workerLocation = (process.env.WORKER_LOCATION || "local").toLowerCase();
const queueNames = FeatureModule.getQueueNames(workerLocation);
FeatureModule.logger.log();
{
: ,
: [
.(
...queueNames.( ({ name })),
),
,
],
: [, ],
: [],
};
}
(: ): [] {
[];
}
}
Step W4: Worker Processor
File: worker/src/{feature}/processors/{feature}.processor.ts
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { FEATURE_QUEUE } from "../{feature}.constants";
import { FeatureService } from "../{feature}.service";
import { FeatureJobDto } from "../dto/{feature}-job.dto";
@Processor(FEATURE_QUEUE, { concurrency: 1 })
export class FeatureProcessor extends WorkerHost {
private readonly logger = new Logger(FeatureProcessor.name);
constructor(private readonly featureService: FeatureService) {
super();
}
async process(job: Job<>): <> {
{ featureId, projectId } = job.;
..();
{
..(job.);
..();
} (error) {
..();
error;
}
}
}
Step W5: Worker Service
File: worker/src/{feature}/{feature}.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { DbService } from "../db/db.service";
import { FeatureJobDto } from "./dto/{feature}-job.dto";
@Injectable()
export class FeatureService {
private readonly logger = new Logger(FeatureService.name);
constructor(private readonly dbService: DbService) {}
async execute(jobData: FeatureJobDto): Promise<void> {
const { featureId, projectId } = jobData;
this.logger.log(`[${featureId}] Starting execution`);
try {
this.logger.log(`[${featureId}] Execution complete`);
} (error) {
..();
error;
}
}
}
Step W6: Register Module
File: worker/src/app.module.ts
@Module({
imports: [
FeatureModule.forRoot(),
],
})
export class AppModule {}
Worker Conventions:
Enqueuing Jobs from the App
File: app/src/lib/services/{feature}-service.ts or inline in server action
import { getQueue } from "@/lib/queue-manager";
import { FEATURE_QUEUE } from "@/lib/queue";
export async function enqueueFeatureJob(data: FeatureJobData) {
const queue = await getQueue(FEATURE_QUEUE);
await queue.add("feature-execute", {
featureId: data.featureId,
projectId: data.projectId,
organizationId: data.organizationId,
config: data.config,
}, {
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
removeOnComplete: { age: 3600 },
removeOnFail: { age: 86400 },
});
}
Testing
Unit Tests (Server Actions / Utils)
File: app/src/actions/create-{feature}.spec.ts
import { createFeature } from "./create-feature";
jest.mock("@/utils/db", () => ({
db: { insert: jest.fn(), select: jest.fn() },
}));
jest.mock("@/lib/project-context", () => ({
requireProjectContext: jest.fn(),
}));
jest.mock("@/lib/rbac/middleware", () => ({
checkPermissionWithContext: jest.fn(),
}));
jest.mock("@/lib/audit-logger", () => ({
logAuditEvent: jest.fn(),
}));
jest.mock("next/cache", () => ({
revalidatePath: jest.fn(),
}));
describe("createFeature", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should create feature when user has permission", async () => {
(requireProjectContext as jest.Mock).mockResolvedValue({
userId: "user-1",
project: { : },
: ,
});
(checkPermissionWithContext jest.).();
result = ({ : });
(result.).();
(logAuditEvent).(
expect.({ : })
);
});
(, () => {
(requireProjectContext jest.).({
: , : { : }, : ,
});
(checkPermissionWithContext jest.).();
result = ({ : });
(result.).();
(result.).();
});
(, () => {
result = ({ : });
(result.).();
});
});
Worker Tests
File: worker/src/{feature}/{feature}.service.spec.ts
describe("FeatureService", () => {
let service: FeatureService;
let dbService: jest.Mocked<DbService>;
beforeEach(() => {
jest.clearAllMocks();
dbService = { } as jest.Mocked<DbService>;
service = new FeatureService(dbService);
});
it("should execute feature job successfully", async () => {
});
it("should throw on execution failure", async () => {
await expect(service.execute(invalidJobData)).rejects.toThrow();
});
});
E2E Tests
File: app/e2e/tests/{feature}/{feature}.spec.ts
import { test, expect } from "@playwright/test";
import { loginIfNeeded } from "../helpers";
test.describe("Features", () => {
test.beforeEach(async ({ page }) => {
await loginIfNeeded(page);
});
test("creates a feature", async ({ page }) => {
await page.goto("/features");
await page.click("text=Create Feature");
await page.fill('[name="name"]', "Test Feature");
await page.click("text=Create");
await expect(page.locator("text=Feature created")).toBeVisible();
});
test("deletes a feature", async ({ page }) => {
});
});
Testing Conventions:
Implementation Checklist
Use this checklist when implementing any new feature:
Database Layer
Validation Layer
Auth / RBAC Layer
Server Actions
API Routes
UI Layer
Worker (if applicable)
Testing
Cross-Cutting
Quick Commands Reference
npm run db:generate
npm run db:migrate
npm run db:studio
npm run lint
npm run build
npm test
npm run e2e
npm run lint
npm run build
npm test
npm test -- src/actions/create-feature.spec.ts
npm test -- src/feature/feature.service.spec.ts