Adding Features
This guide walks through the complete process of implementing a new feature in Sayr, from database schema to UI. We’ll use a simplified example of adding a “task bookmark” feature to demonstrate each layer of the stack.
Overview
Section titled “Overview”A typical feature touches these layers:
- Database schema - Define the data model
- Database functions - CRUD operations
- Backend API route - HTTP endpoint with auth/permissions
- Frontend fetch function - API wrapper
- Frontend component - User interface
- Real-time updates - WebSocket broadcasting
Step 1: Database Schema
Section titled “Step 1: Database Schema”Start in packages/database/src/schema/ by creating or modifying schema files.
Create a new schema file
Section titled “Create a new schema file”import { pgTable, timestamp, primaryKey } from "drizzle-orm/pg-core";import { user } from "./user.schema";import { task } from "./task.schema";import { organization } from "./organization.schema";
export const taskBookmark = pgTable( "task_bookmark", { taskId: text("task_id") .notNull() .references(() => task.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), organizationId: text("organization_id") .notNull() .references(() => organization.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => ({ pk: primaryKey({ columns: [table.taskId, table.userId] }), }));Add relations
Section titled “Add relations”// In the same file or a relations fileimport { relations } from "drizzle-orm";
export const taskBookmarkRelations = relations(taskBookmark, ({ one }) => ({ task: one(task, { fields: [taskBookmark.taskId], references: [task.id], }), user: one(user, { fields: [taskBookmark.userId], references: [user.id], }),}));Export from schema index
Section titled “Export from schema index”export * from "./taskBookmark.schema";
// Add type exportsexport type TaskBookmark = typeof taskBookmark.$inferSelect;export type NewTaskBookmark = typeof taskBookmark.$inferInsert;Apply to database
Section titled “Apply to database”pnpm -F @repo/database db:pushStep 2: Database Functions
Section titled “Step 2: Database Functions”Create CRUD functions in packages/database/src/functions/.
import { and, eq } from "drizzle-orm";import { db, schema } from "..";
/** * Toggle a bookmark for a task. If it exists, remove it. If not, create it. * * @param taskId - The task to bookmark * @param userId - The user bookmarking * @param orgId - The organization context * @returns The bookmark if created, null if removed */export async function toggleTaskBookmark( taskId: string, userId: string, orgId: string): Promise<schema.TaskBookmark | null> { // Check if bookmark exists const existing = await db.query.taskBookmark.findFirst({ where: and( eq(schema.taskBookmark.taskId, taskId), eq(schema.taskBookmark.userId, userId) ), });
if (existing) { // Remove bookmark await db .delete(schema.taskBookmark) .where( and( eq(schema.taskBookmark.taskId, taskId), eq(schema.taskBookmark.userId, userId) ) ); return null; }
// Create bookmark const [bookmark] = await db .insert(schema.taskBookmark) .values({ taskId, userId, organizationId: orgId }) .returning();
return bookmark;}
/** * Get all bookmarked tasks for a user in an organization. */export async function getUserBookmarks( userId: string, orgId: string): Promise<schema.TaskBookmark[]> { return db.query.taskBookmark.findMany({ where: and( eq(schema.taskBookmark.userId, userId), eq(schema.taskBookmark.organizationId, orgId) ), with: { task: true, }, });}
/** * Check if a user has bookmarked a specific task. */export async function isTaskBookmarked( taskId: string, userId: string): Promise<boolean> { const bookmark = await db.query.taskBookmark.findFirst({ where: and( eq(schema.taskBookmark.taskId, taskId), eq(schema.taskBookmark.userId, userId) ), }); return !!bookmark;}Export functions
Section titled “Export functions”export * from "./functions/taskBookmark";Step 3: Backend API Route
Section titled “Step 3: Backend API Route”Create or extend a route in apps/backend/routes/api/.
// apps/backend/routes/api/task.ts (add to existing file)import { toggleTaskBookmark, getUserBookmarks,} from "@repo/database";import { createTraceAsync } from "@repo/opentelemetry/trace";import { traceOrgPermissionCheck } from "@/util";import { broadcast, broadcastIndividual, findClientsByUserId } from "../ws";import type { WSBaseMessage } from "@/routes/ws/types";
// Toggle bookmark endpointapiRouteAdminProjectTask.post("/bookmark", async (c) => { const traceAsync = createTraceAsync(); const recordWideError = c.get("recordWideError"); const session = c.get("session");
// 1. Parse request const { org_id: orgId, task_id: taskId, wsClientId } = await c.req.json();
// 2. Validate session if (!session?.userId) { return c.json({ success: false, error: "Not authenticated" }, 401); }
// 3. Check permissions (optional - bookmarks might not need special perms) const isAuthorized = await traceOrgPermissionCheck(session.userId, orgId, "tasks.view");
if (!isAuthorized) { return c.json({ success: false, error: "Permission denied" }, 401); }
// 4. Perform the operation try { const bookmark = await traceAsync( "task.bookmark.toggle", () => toggleTaskBookmark(taskId, session.userId, orgId), { description: "Toggling task bookmark", data: { taskId, userId: session.userId }, } );
const isBookmarked = bookmark !== null;
// 5. Broadcast to user's other sessions (optional) const userSockets = findClientsByUserId(session.userId); for (const socket of userSockets) { broadcastIndividual( socket, { type: "BOOKMARK_CHANGED" as WSBaseMessage["type"], data: { taskId, isBookmarked }, }, orgId ); }
// 6. Return response return c.json({ success: true, data: { taskId, isBookmarked }, }); } catch (err) { await recordWideError({ name: "task.bookmark.failed", error: err, code: "TASK_BOOKMARK_FAILED", message: "Failed to toggle bookmark", contextData: { taskId, userId: session.userId }, }); return c.json({ success: false, error: "Failed to toggle bookmark" }, 500); }});Key patterns in the API route
Section titled “Key patterns in the API route”| Step | Purpose |
|---|---|
| Parse request | Extract data from c.req.json() |
| Validate session | Check c.get("session") exists |
| Check permissions | Use traceOrgPermissionCheck() for authorization |
| Wrap with tracing | Use traceAsync() for observability |
| Handle errors | Use recordWideError() for logging |
| Broadcast updates | Use broadcast() for real-time sync |
Step 4: Frontend Fetch Function
Section titled “Step 4: Frontend Fetch Function”Create API wrapper functions in apps/start/src/lib/fetches/.
// apps/start/src/lib/fetches/task.ts (add to existing file)
const API_URL = import.meta.env.VITE_APP_ENV === "development" ? "/backend-api" : "/api";
/** * Toggle bookmark status for a task. * * @param organizationId - The organization the task belongs to * @param taskId - The task to bookmark/unbookmark * @param wsClientId - WebSocket client ID for real-time updates * @returns Promise with success status and bookmark state * * @example * ```ts * const result = await toggleBookmarkAction("org_123", "task_456", wsClientId); * if (result.success) { * console.log("Bookmarked:", result.data.isBookmarked); * } * ``` */export async function toggleBookmarkAction( organizationId: string, taskId: string, wsClientId: string): Promise<{ success: boolean; data?: { taskId: string; isBookmarked: boolean }; error?: string }> { const result = await fetch(`${API_URL}/admin/organization/task/bookmark`, { method: "POST", body: JSON.stringify({ org_id: organizationId, task_id: taskId, wsClientId, }), headers: { "Content-Type": "application/json", }, credentials: "include", }).then((res) => res.json());
if (!result.success) { console.error("Failed to toggle bookmark", { error: result.error, taskId }); }
return result;}Step 5: Frontend Component
Section titled “Step 5: Frontend Component”Create the UI component in apps/start/src/components/.
"use client";
import { useState } from "react";import { Button } from "@repo/ui/components/button";import { IconBookmark, IconBookmarkFilled } from "@tabler/icons-react";import { useStateManagement } from "@repo/ui/hooks/useStateManagement";import { toggleBookmarkAction } from "@/lib/fetches/task";import { useToastAction } from "@/lib/util";
interface BookmarkButtonProps { taskId: string; organizationId: string; initialBookmarked?: boolean;}
export function BookmarkButton({ taskId, organizationId, initialBookmarked = false,}: BookmarkButtonProps) { // 1. Hooks first const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); const { value: wsClientId } = useStateManagement<string>("ws-clientId", ""); const { runWithToast, isFetching } = useToastAction();
// 2. Handlers const handleToggle = async () => { if (isFetching) return;
const result = await runWithToast( "bookmark-toggle", { loading: { title: "Updating bookmark..." }, success: { title: isBookmarked ? "Bookmark removed" : "Bookmark added", }, error: { title: "Failed to update bookmark" }, }, () => toggleBookmarkAction(organizationId, taskId, wsClientId) );
if (result?.success && result.data) { setIsBookmarked(result.data.isBookmarked); } };
// 3. Render return ( <Button variant="ghost" size="sm" onClick={handleToggle} disabled={isFetching} aria-label={isBookmarked ? "Remove bookmark" : "Add bookmark"} > {isBookmarked ? ( <IconBookmarkFilled className="h-4 w-4 text-primary" /> ) : ( <IconBookmark className="h-4 w-4" /> )} </Button> );}Using the component
Section titled “Using the component”// In a task detail page or task cardimport { BookmarkButton } from "@/components/tasks/task/bookmark-button";
function TaskCard({ task, organization }) { return ( <div className="flex items-center gap-2"> <h3>{task.title}</h3> <BookmarkButton taskId={task.id} organizationId={organization.id} initialBookmarked={task.isBookmarked} /> </div> );}Step 6: Real-time Updates (WebSocket)
Section titled “Step 6: Real-time Updates (WebSocket)”For features that need real-time sync across clients, add WebSocket message handling.
Add message type
Section titled “Add message type”export type WSMessageType = | "CREATE_TASK" | "UPDATE_TASK" | "DELETE_TASK" | "BOOKMARK_CHANGED" // Add new type // ... other typesHandle in frontend
Section titled “Handle in frontend”// In a component or context that manages WebSocketuseEffect(() => { const handleMessage = (event: MessageEvent) => { const message = JSON.parse(event.data);
if (message.type === "BOOKMARK_CHANGED") { const { taskId, isBookmarked } = message.data; // Update local state setBookmarks((prev) => isBookmarked ? [...prev, taskId] : prev.filter((id) => id !== taskId) ); } };
ws.addEventListener("message", handleMessage); return () => ws.removeEventListener("message", handleMessage);}, []);Complete Feature Checklist
Section titled “Complete Feature Checklist”Before submitting your PR, verify:
- Database schema created and exported
- Relations defined if needed
- Database functions with JSDoc comments
- Functions exported from
packages/database/src/index.ts - API route with permission checking
- Error handling with
recordWideError() - Tracing with
createTraceAsync() - Frontend fetch function with JSDoc
- React component following structure guidelines
- WebSocket broadcasting (if real-time needed)
- Types exported for consumers
-
pnpm lintpasses -
pnpm check-typespasses
Common Patterns Reference
Section titled “Common Patterns Reference”Permission check
Section titled “Permission check”const isAuthorized = await traceOrgPermissionCheck(session.userId, orgId, "tasks.create");if (!isAuthorized) { return c.json({ success: false, error: "Permission denied" }, 401);}Error recording
Section titled “Error recording”await recordWideError({ name: "feature.action.failed", error: err, code: "FEATURE_ACTION_FAILED", message: "Human readable message", contextData: { relevantIds },});Broadcast to channel
Section titled “Broadcast to channel”broadcast(orgId, "tasks", { type: "UPDATE_TASK", data: task }, wsClientId);Toast with loading state
Section titled “Toast with loading state”const { runWithToast, isFetching } = useToastAction();
const result = await runWithToast( "unique-toast-id", { loading: { title: "Working..." }, success: { title: "Done!" }, error: { title: "Failed" }, }, () => apiCall());File Locations Summary
Section titled “File Locations Summary”| Layer | Location |
|---|---|
| Schema | packages/database/src/schema/ |
| DB functions | packages/database/src/functions/ |
| API routes | apps/backend/routes/api/ |
| Fetch functions | apps/start/src/lib/fetches/ |
| Components | apps/start/src/components/ |
| WS types | apps/backend/routes/ws/types.ts |
Related Guides
Section titled “Related Guides”- Architecture Overview — Understand how the systems connect
- Database Guide — Detailed Drizzle ORM patterns
- Code Style Guide — Coding conventions and error handling
- Testing Guide — Writing tests for your feature
- Pull Request Guidelines — Submitting your changes