Products & Data Management

Products is the sample workspace-scoped CRUD: server pagination, filters, soft delete, comments, tags, CSV import/export. Fork the pattern or delete the module once your real entity exists.

To replace Products with your own entity (Tasks, Contacts, Tickets, etc.), see Adding Your Own Model.

Data table

The Products page at /w/[slug]/products features a full-featured data table built on @tanstack/react-table.

Table features

FeatureDescription
Server-side pagination20 items per page (configurable). Page state synced to URL.
Sortable columnsClick column headers to sort by name, status, or creation date.
Faceted filteringFilter by status (DRAFT, ACTIVE, ARCHIVED) with count badges.
Full-text searchSearch products by name with debounced input.
Column visibilityToggle which columns are visible via a dropdown.
Row selectionSelect individual rows or all rows on the current page.
Bulk actionsDelete Selected, Archive Selected, Export CSV for selected rows.
URL-driven stateAll filters, sorting, and pagination are encoded in URL params.

URL parameters

The table state is fully encoded in the URL, making views shareable and bookmarkable:

/w/my-workspace/products?page=2&sort=name:asc&status=ACTIVE,DRAFT&search=widget

Where to customize

  • Data table component: src/app/(app)/w/[workspaceSlug]/products/products-data-table.tsx
  • Page (server component): src/app/(app)/w/[workspaceSlug]/products/page.tsx
  • Page size: Change the default in the page component's fetch query

Reusable data table components

The data table is built from composable components in src/components/data-table/:

ComponentPurpose
DataTableMain table with pagination, sorting, search, and bulk actions
DataTableColumnHeaderSortable column header
DataTableFacetedFilterMulti-select filter popover with counts
DataTableColumnToggleColumn visibility dropdown
DataTableBulkBarFloating action bar for selected rows
DataTablePaginationPage navigation controls

Use these same components when building data tables for your own entities.

CRUD operations

All product operations are implemented as server actions in src/app/(app)/w/[workspaceSlug]/products/actions.ts.

Available actions

ActionRolesDescription
createProductOWNER, ADMIN, MEMBERCreate a new product (checks plan limits)
updateProductOWNER, ADMIN, MEMBERUpdate name, description, or status
softDeleteProductOWNER, ADMIN, MEMBERMove to trash (sets deleted_at)
permanentDeleteProductOWNER, ADMINPermanently remove from database
restoreFromTrashOWNER, ADMIN, MEMBERRestore from trash (clears deleted_at)
archiveProductOWNER, ADMIN, MEMBERSet status to ARCHIVED
restoreProductOWNER, ADMIN, MEMBERSet status to ACTIVE
bulkSoftDeleteOWNER, ADMIN, MEMBERBulk move to trash
bulkUpdateStatusOWNER, ADMIN, MEMBERBulk status change

Server action pattern

Every action follows the same pattern:

export async function createProduct(workspaceId: string, formData: FormData) {
  // 1. Authenticate
  const user = await requireUser();
  // 2. Authorize
  await requireRole(workspaceId, user.id, ["OWNER", "ADMIN", "MEMBER"]);
  // 3. Check plan limits
  const limitCheck = await checkLimit(workspaceId, "records");
  if (!limitCheck.allowed) return { error: "Plan limit reached." };
  // 4. Validate input with Zod
  const parsed = schema.safeParse({ ... });
  // 5. Insert with workspace_id scoping
  await supabase.from("products").insert({ workspace_id: workspaceId, ... });
  // 6. Audit log
  await insertAuditLog({ workspaceId, actorUserId: user.id, action: "product.created", ... });
  // 7. Revalidate the page
  revalidatePath(`/w/${workspaceSlug}/products`);
}

Product statuses

StatusDescription
DRAFTDefault on creation. Not yet published.
ACTIVELive / published.
ARCHIVEDHidden from active lists.

Status badges are displayed in the data table. Click a badge to change the status inline via a dropdown.

Soft delete and trash

Products support soft delete -- "deleting" a product sets deleted_at instead of removing the row.

How it works

  • Active products: Queried with WHERE deleted_at IS NULL
  • Trashed products: Queried with WHERE deleted_at IS NOT NULL
  • Restore: Clears deleted_at back to NULL
  • Permanent delete: Actually removes the row (restricted to OWNER/ADMIN)

The Trash tab

The Products page has two tabs:

  1. Active -- shows products where deleted_at IS NULL
  2. Trash -- shows soft-deleted products with Restore and Permanent Delete actions

Where to customize

To add soft delete to your own tables:

  1. Add a deleted_at TIMESTAMPTZ column (default NULL)
  2. Add partial indexes for both active and trashed queries
  3. Filter by deleted_at IS NULL in your default queries
  4. See supabase/003_soft_delete_and_dashboard.sql for the full pattern

CSV import and export

Export

Select rows in the data table and use the Export CSV bulk action to download selected products as a CSV file.

Import

Click the Import button to open the CSV import wizard:

  1. Upload a CSV file
  2. The wizard parses and validates the data
  3. Review the parsed rows
  4. Confirm to bulk-create products

Where to customize

  • Import action: src/app/(app)/w/[workspaceSlug]/products/import-action.ts
  • Import button: src/app/(app)/w/[workspaceSlug]/products/product-import-button.tsx
  • Import wizard components: src/components/import-wizard/

Product detail page

Each product has a detail page at /w/[slug]/products/[productId] that shows:

  • Full product details (name, description, status, dates)
  • Edit and archive controls
  • Tags assigned to the product
  • Comments thread
  • File attachments
  • Activity feed (recent audit events for this product)

Where to customize

  • Detail page: src/app/(app)/w/[workspaceSlug]/products/[productId]/page.tsx
  • Product form (create/edit dialog): src/app/(app)/w/[workspaceSlug]/products/product-form.tsx

Tags

Tags are workspace-scoped colored labels that can be assigned to products (or any record type).

How it works

  • Tags are created at the workspace level with a name and color
  • Any member can assign/remove tags on products
  • Only OWNER/ADMIN can create or delete tag definitions
  • Tags are stored in the tags table, assignments in record_tags

Where to customize

  • Tag actions: src/lib/tags/tag-actions.ts
  • Tag management UI: src/app/(app)/w/[workspaceSlug]/settings/tags-section.tsx
  • Database: supabase/005_tags_comments_2fa.sql

Using tags with other entities

Tags work with any record type. To use them with your own entity:

import { addTagToRecord, removeTagFromRecord, getRecordTags } from "@/lib/tags/tag-actions";

// Add a tag
await addTagToRecord(workspaceId, taskId, tagId, "task");

// Get tags for a record
const tags = await getRecordTags(workspaceId, taskId, "task");

Comments

A threaded comment system that can be attached to any workspace record.

Features

  • Add, edit, and delete comments
  • Threaded replies (parent/child comments)
  • @mentions with user UUIDs
  • Character limit: 1-5000 characters
  • Only authors can edit their own comments
  • OWNER/ADMIN can delete any comment

Where to customize

  • Comment actions: src/lib/comments/comment-actions.ts
  • Database: supabase/005_tags_comments_2fa.sql

Using comments with other entities

import { addComment, getComments } from "@/lib/comments/comment-actions";

// Add a comment
await addComment(workspaceId, taskId, "task", "Great work on this!", [mentionUserId]);

// Get comments
const comments = await getComments(workspaceId, taskId, "task");

File attachments

Upload and manage files attached to any workspace record using Supabase Storage.

Features

  • Upload files to products (or any record type)
  • File metadata stored: name, size, MIME type, storage path
  • Uploaders can delete their own files
  • OWNER/ADMIN can delete any file

Where to customize

  • Attachment actions: src/lib/attachments/attachment-actions.ts
  • Database: supabase/006_remaining_features.sql

Using attachments with other entities

import { createAttachmentRecord, getAttachments } from "@/lib/attachments/attachment-actions";

// Create an attachment record after uploading to Supabase Storage
await createAttachmentRecord(workspaceId, taskId, "task", fileName, fileSize, mimeType, storagePath);

// Get attachments
const files = await getAttachments(workspaceId, taskId, "task");

Plan limit enforcement

Product creation checks subscription plan limits before allowing the operation:

const limitCheck = await checkLimit(workspaceId, "records");
if (!limitCheck.allowed) return { error: "Plan limit reached." };

Plans define limits for:

  • Seats -- maximum number of workspace members
  • Records -- maximum number of products (Enterprise = unlimited)

See Billing & Subscriptions for details on plan configuration.