From tour-dev-toolkit
This skill should be used when the user asks to "scaffold an action", "create a server action", "generate action file", "new action for", or needs a new server action file following project conventions.
npx claudepluginhub boburshoh122000/claude-plugins-bobur --plugin tour-dev-toolkitThis skill uses the workspace's default tool permissions.
Generate a server action file and its co-located test file following project conventions.
Verifies tests pass on completed feature branch, presents options to merge locally, create GitHub PR, keep as-is or discard; executes choice and cleans up worktree.
Guides root cause investigation for bugs, test failures, unexpected behavior, performance issues, and build failures before proposing fixes.
Writes implementation plans from specs for multi-step tasks, mapping files and breaking into TDD bite-sized steps before coding.
Generate a server action file and its co-located test file following project conventions.
Accept a kebab-case name from the user (e.g., expense-category, traveler-document).
{name} = kebab-case as provided (e.g., expense-category){PascalName} = PascalCase conversion (e.g., ExpenseCategory){modelName} = camelCase Prisma model name (e.g., expenseCategory){ENTITY_TYPE} = UPPER_SNAKE_CASE for activity logs (e.g., EXPENSE_CATEGORY)src/app/lib/{name}-actions.tsWrite the file using this template:
'use server';
import { z } from 'zod';
import { prisma } from '@/lib/db';
import { requireUser } from '@/lib/auth-guard';
import { logActivity } from '@/lib/activity-logger';
import { revalidatePath } from 'next/cache';
// ─── Zod Schemas ────────────────────────────────────────────────────────────
const Create{PascalName}Schema = z.object({
name: z.string().min(1),
// TODO: Add fields from prisma schema — use .gt(0) not .positive() (Zod v4)
});
const Update{PascalName}Schema = z.object({
name: z.string().min(1),
// TODO: Add fields from prisma schema — use .gt(0) not .positive() (Zod v4)
});
// ─── Create ─────────────────────────────────────────────────────────────────
export async function create{PascalName}(data: z.infer<typeof Create{PascalName}Schema>) {
try {
// 1. Auth
const user = await requireUser();
if (!user.organization_id) return { error: 'No organization' };
// 2. Validate
const validated = Create{PascalName}Schema.parse(data);
// 3. DB operation — scoped to organization
const record = await prisma.{modelName}.create({
data: {
...validated,
organization_id: user.organization_id,
},
});
// 4. Audit
await logActivity({
action: '{ENTITY_TYPE}_CREATED',
description: `Created {name}: ${validated.name}`,
entityType: '{name}',
entityId: record.id,
userId: user.id,
organizationId: user.organization_id,
});
// 5. Revalidate
revalidatePath('/{name}s');
return { success: true, id: record.id };
} catch (e) {
console.error(e);
return { error: 'Failed to create {name}' };
}
}
// ─── Update ─────────────────────────────────────────────────────────────────
export async function update{PascalName}(id: string, data: z.infer<typeof Update{PascalName}Schema>) {
try {
// 1. Auth
const user = await requireUser();
if (!user.organization_id) return { error: 'No organization' };
// 2. Validate
const validated = Update{PascalName}Schema.parse(data);
// 3. DB operation — org-scoped via updateMany
const result = await prisma.{modelName}.updateMany({
where: { id, organization_id: user.organization_id },
data: validated,
});
if (result.count === 0) return { error: '{PascalName} not found or access denied' };
// 4. Audit
await logActivity({
action: '{ENTITY_TYPE}_UPDATED',
description: `Updated {name}: ${validated.name}`,
entityType: '{name}',
entityId: id,
userId: user.id,
organizationId: user.organization_id,
});
// 5. Revalidate
revalidatePath('/{name}s');
return { success: true };
} catch (e) {
console.error(e);
return { error: 'Failed to update {name}' };
}
}
// ─── Delete ─────────────────────────────────────────────────────────────────
export async function delete{PascalName}(id: string) {
try {
// 1. Auth
const user = await requireUser();
if (!user.organization_id) return { error: 'No organization' };
// 2. Verify ownership
const record = await prisma.{modelName}.findFirst({
where: { id, organization_id: user.organization_id },
});
if (!record) return { error: '{PascalName} not found or access denied' };
// 3. Soft-delete (preferred) or hard-delete
await prisma.{modelName}.update({
where: { id },
data: { deleted_at: new Date() },
});
// 4. Audit
await logActivity({
action: '{ENTITY_TYPE}_DELETED',
description: `Deleted {name}: ${id}`,
entityType: '{name}',
entityId: id,
userId: user.id,
organizationId: user.organization_id,
});
// 5. Revalidate
revalidatePath('/{name}s');
return { success: true };
} catch (e) {
console.error(e);
return { error: 'Failed to delete {name}' };
}
}
// ─── Get by ID ──────────────────────────────────────────────────────────────
export async function get{PascalName}(id: string) {
try {
const user = await requireUser();
if (!user.organization_id) return { error: 'No organization' };
const record = await prisma.{modelName}.findFirst({
where: { id, organization_id: user.organization_id },
});
if (!record) return { error: '{PascalName} not found or access denied' };
return { success: true, data: record };
} catch (e) {
console.error(e);
return { error: 'Failed to get {name}' };
}
}
src/app/lib/{name}-actions.test.tsWrite the test file using this template:
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ────────────────────────────────────────────────────────────────────
const mocks = vi.hoisted(() => ({
requireUser: vi.fn(),
{modelName}Create: vi.fn(),
{modelName}UpdateMany: vi.fn(),
{modelName}FindFirst: vi.fn(),
{modelName}Update: vi.fn(),
logActivity: vi.fn(),
revalidatePath: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: {
{modelName}: {
create: mocks.{modelName}Create,
updateMany: mocks.{modelName}UpdateMany,
findFirst: mocks.{modelName}FindFirst,
update: mocks.{modelName}Update,
},
},
}));
vi.mock('@/lib/auth-guard', () => ({ requireUser: mocks.requireUser }));
vi.mock('@/lib/activity-logger', () => ({ logActivity: mocks.logActivity }));
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }));
import {
create{PascalName},
update{PascalName},
delete{PascalName},
get{PascalName},
} from './{name}-actions';
// ── Helpers ──────────────────────────────────────────────────────────────────
function authedUser(orgId = 'org-1') {
return { id: 'user-1', organization_id: orgId };
}
const validData = {
name: 'Test {PascalName}',
// TODO: Fill in remaining required fields
};
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.requireUser.mockResolvedValue(authedUser());
mocks.{modelName}Create.mockResolvedValue({ id: 'rec-new' });
mocks.{modelName}UpdateMany.mockResolvedValue({ count: 1 });
mocks.{modelName}FindFirst.mockResolvedValue({ id: 'rec-1' });
mocks.{modelName}Update.mockResolvedValue({ id: 'rec-1' });
mocks.logActivity.mockResolvedValue(undefined);
});
// ── create{PascalName} ──────────────────────────────────────────────────────
describe('create{PascalName}', () => {
it('blocks unauthenticated users', async () => {
mocks.requireUser.mockRejectedValue(new Error('Not authenticated'));
const result = await create{PascalName}(validData);
expect(result.error).toBeTruthy();
});
it('returns error when no organization', async () => {
mocks.requireUser.mockResolvedValue({ id: 'u-1', organization_id: null });
const result = await create{PascalName}(validData);
expect(result.error).toMatch(/organization/i);
});
it('creates record scoped to organization', async () => {
const result = await create{PascalName}(validData);
expect(result.success).toBe(true);
expect(mocks.{modelName}Create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ organization_id: 'org-1' }),
}),
);
});
it('logs activity after creation', async () => {
await create{PascalName}(validData);
expect(mocks.logActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: '{ENTITY_TYPE}_CREATED' }),
);
});
});
// ── update{PascalName} ──────────────────────────────────────────────────────
describe('update{PascalName}', () => {
it('returns error when record not in org (IDOR guard)', async () => {
mocks.{modelName}UpdateMany.mockResolvedValue({ count: 0 });
const result = await update{PascalName}('rec-other', validData);
expect(result.error).toMatch(/not found/i);
});
it('updates record scoped to org via updateMany', async () => {
const result = await update{PascalName}('rec-1', validData);
expect(result.success).toBe(true);
expect(mocks.{modelName}UpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'rec-1', organization_id: 'org-1' },
}),
);
});
});
// ── delete{PascalName} ──────────────────────────────────────────────────────
describe('delete{PascalName}', () => {
it('returns error when record not in org (IDOR guard)', async () => {
mocks.{modelName}FindFirst.mockResolvedValue(null);
const result = await delete{PascalName}('rec-other');
expect(result.error).toMatch(/not found/i);
});
it('soft-deletes record (sets deleted_at)', async () => {
const result = await delete{PascalName}('rec-1');
expect(result.success).toBe(true);
expect(mocks.{modelName}Update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'rec-1' },
data: { deleted_at: expect.any(Date) },
}),
);
});
});
// ── get{PascalName} ─────────────────────────────────────────────────────────
describe('get{PascalName}', () => {
it('returns error when record not in org', async () => {
mocks.{modelName}FindFirst.mockResolvedValue(null);
const result = await get{PascalName}('rec-other');
expect(result.error).toMatch(/not found/i);
});
it('returns record when found in org', async () => {
const result = await get{PascalName}('rec-1');
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
});
});
prisma/schema.prisma for the actual Prisma model name and verify the {modelName} substitution matches..gt(0) instead of .positive() and .gte(0) instead of .nonnegative() (Zod v4 conventions).organization_id field for multi-tenant scoping. If it uses a parent relation instead (e.g., tour.organization_id), adjust the where clauses.deleted_at field for soft-delete. If not, use prisma.{modelName}.delete() for hard-delete.revalidatePath calls to match actual app routes.node node_modules/vitest/vitest.mjs run src/app/lib/{name}-actions.test.ts