Adding Features
A complete walkthrough for implementing new features in Sayr
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
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 - SSE broadcasting
Step 1: Database Schema
Start in packages/database/src/schema/ by creating or modifying schema files.
Create a new schema file
// packages/database/src/schema/taskBookmark.schema.ts
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
// In the same file or a relations file
import { 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
// packages/database/src/schema/index.ts
export * from "./taskBookmark.schema";
// Add type exports
export type TaskBookmark = typeof taskBookmark.$inferSelect;
export type NewTaskBookmark = typeof taskBookmark.$inferInsert;Apply to database
pnpm -F @repo/database db:pushStep 2: Database Functions
Create CRUD functions in packages/database/src/functions/.
// packages/database/src/functions/taskBookmark.ts
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
// packages/database/src/index.ts
export * from "./functions/taskBookmark";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 endpoint
apiRouteAdminProjectTask.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, sseClientId } = 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
| 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
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 sseClientId - SSE client ID for real-time updates
* @returns Promise with success status and bookmark state
*
* @example
* ```ts
* const result = await toggleBookmarkAction("org_123", "task_456", sseClientId);
* if (result.success) {
* console.log("Bookmarked:", result.data.isBookmarked);
* }
* ```
*/
export async function toggleBookmarkAction(
organizationId: string,
taskId: string,
sseClientId: 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,
sseClientId,
}),
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
Create the UI component in apps/start/src/components/.
// apps/start/src/components/tasks/task/bookmark-button.tsx
"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: sseClientId } = useStateManagement<string>("sse-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, sseClientId)
);
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
// In a task detail page or task card
import { 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 Server‑Sent Events (SSE)
For features that need real-time sync across clients, add SSE message handling.
Add message type
// apps/backend/routes/events/types.ts
export type ServerEventBaseMessage =
| "CREATE_TASK"
| "UPDATE_TASK"
| "DELETE_TASK"
| "BOOKMARK_CHANGED" // Add new type
// ... other typesHandle in frontend
// In a component or context that manages SSE
useEffect(() => {
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)
);
}
};
serverEvents.event.addEventListener("message", handleMessage);
return () => serverEvents.event.removeEventListener("message", handleMessage);
}, []);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
- SSE broadcasting (if real-time needed)
- Types exported for consumers
-
pnpm lintpasses -
pnpm check-typespasses
Common Patterns Reference
Permission check
const isAuthorized = await traceOrgPermissionCheck(session.userId, orgId, "tasks.create");
if (!isAuthorized) {
return c.json({ success: false, error: "Permission denied" }, 401);
}Error recording
await recordWideError({
name: "feature.action.failed",
error: err,
code: "FEATURE_ACTION_FAILED",
message: "Human readable message",
contextData: { relevantIds },
});Broadcast to channel
broadcast(orgId, "tasks", { type: "UPDATE_TASK", data: task }, sseClientId);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
| 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
- 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