Files
myeasycms-v2/docs/development/database-schema.mdoc
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

542 lines
16 KiB
Plaintext

---
status: "published"
label: "Extending the DB Schema"
order: 2
title: "Extending the Database Schema in Next.js Supabase"
description: "Learn how to create new migrations and update the database schema in your Next.js Supabase application"
---
{% sequence title="Steps to create a new migration" description="Learn how to create new migrations and update the database schema in your Next.js Supabase application" %}
[Planning Your Schema Extension](#planning-your-schema-extension)
[Creating Schema Files](#creating-schema-files)
[Permissions and Access Control](#permissions-and-access-control)
[Building Tables with RLS](#building-tables-with-rls)
[Advanced Patterns](#advanced-patterns)
[Testing and Deployment](#testing-and-deployment)
{% /sequence %}
This guide walks you through extending Makerkit's database schema with new tables and features. We'll use a comprehensive example that demonstrates best practices, security patterns, and integration with Makerkit's multi-tenant architecture.
## Planning Your Schema Extension
Before writing any SQL, it's crucial to understand how your new features fit into Makerkit's multi-tenant architecture.
### Decision Framework
**Step 1: Determine Data Ownership**
Ask yourself: "Who owns this data - individual users or accounts?"
- **User-owned data**: Personal preferences, activity logs, user settings
- **Account-owned data**: Business content, shared resources, collaborative features
**Step 2: Define Access Patterns**
- **Public within account**: All team members can access
- **Private within account**: Only creator + specific permissions
- **Admin-only**: Requires special permissions or super admin access
**Step 3: Consider Integration Points**
- Does this feature affect billing? (usage tracking, feature gates)
- Does it need notifications? (in-app alerts, email triggers)
- Should it have audit trails? (compliance, change tracking)
## Creating Schema Files
Makerkit organizes database schema in numbered files for proper ordering. Follow this workflow:
### 1. Create Your Schema File
```bash
# Create a new schema file with the next number
touch apps/web/supabase/schemas/18-notes-feature.sql
```
### 2. Apply Development Workflow
```bash
# Start Supabase
pnpm supabase:web:start
# Create migration from your schema file
pnpm --filter web run supabase:db:diff -f notes-feature
# Restart with new schema
pnpm supabase:web:reset
# Generate TypeScript types
pnpm supabase:web:typegen
```
## Permissions and Access Control
### Adding New Permissions
Makerkit defines permissions in the `public.app_permissions` enum. Add feature-specific permissions:
```sql
-- Add new permissions for your feature
ALTER TYPE public.app_permissions ADD VALUE 'notes.create';
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
ALTER TYPE public.app_permissions ADD VALUE 'notes.delete';
COMMIT;
```
**Note:** The Supabase diff function does not support adding new permissions to enum types. Please add the new permissions manually instead of using the diff function.
**Permission Naming Convention**: Use the pattern `resource.action` for consistency:
- `notes.create` - Create new notes
- `notes.manage` - Edit existing notes
- `notes.delete` - Delete notes
- `notes.share` - Share with external users
### Role Assignment
Consider which roles should have which permissions by default:
```sql
-- Grant permissions to roles
INSERT INTO public.role_permissions (role, permission) VALUES
('owner', 'notes.create'),
('owner', 'notes.manage'),
('owner', 'notes.delete'),
('owner', 'notes.share'),
('member', 'notes.create'),
('member', 'notes.manage');
```
## Building Tables with RLS
Let's create a comprehensive notes feature that demonstrates various patterns and best practices.
### Core Notes Table
```sql
-- Create the main notes table with all standard fields
CREATE TABLE IF NOT EXISTS public.notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
title varchar(500) NOT NULL,
content text,
is_published boolean NOT NULL DEFAULT false,
tags text[] DEFAULT '{}',
metadata jsonb DEFAULT '{}',
-- Audit fields (always include these)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
updated_by uuid REFERENCES auth.users(id),
-- Data integrity constraints
CONSTRAINT notes_title_length CHECK (length(title) >= 1),
CONSTRAINT notes_account_required CHECK (account_id IS NOT NULL)
);
-- Add helpful comments for documentation
COMMENT ON TABLE public.notes IS 'User-generated notes with sharing capabilities';
COMMENT ON COLUMN public.notes.account_id IS 'Account that owns this note (multi-tenant isolation)';
COMMENT ON COLUMN public.notes.is_published IS 'Whether note is visible to all account members';
COMMENT ON COLUMN public.notes.tags IS 'Searchable tags for categorization';
COMMENT ON COLUMN public.notes.metadata IS 'Flexible metadata (view preferences, etc.)';
```
### Performance Indexes
Consider creating indexes for your query patterns if you are scaling to a large number of records.
```sql
-- Essential indexes for performance
CREATE INDEX idx_notes_account_id ON public.notes(account_id);
CREATE INDEX idx_notes_created_at ON public.notes(created_at DESC);
CREATE INDEX idx_notes_account_created ON public.notes(account_id, created_at DESC);
CREATE INDEX idx_notes_published ON public.notes(account_id, is_published) WHERE is_published = true;
CREATE INDEX idx_notes_tags ON public.notes USING gin(tags);
```
### Security Setup
```sql
-- Always enable RLS (NEVER skip this!)
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
-- Revoke default permissions and grant explicitly
REVOKE ALL ON public.notes FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated, service_role;
```
### RLS Policies
Create comprehensive policies that handle both personal and team accounts:
```sql
-- SELECT policy: Read published notes or own private notes
CREATE POLICY "notes_select" ON public.notes
FOR SELECT TO authenticated
USING (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: member can read published notes
(public.has_role_on_account(account_id) AND is_published = true)
OR
-- Team account: creator can read their own drafts
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
-- Team account: users with manage permission can read all
public.has_permission(auth.uid(), account_id, 'notes.manage')
);
-- INSERT policy: Must have create permission
CREATE POLICY "notes_insert" ON public.notes
FOR INSERT TO authenticated
WITH CHECK (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: must have create permission
public.has_permission(auth.uid(), account_id, 'notes.create')
);
-- UPDATE policy: Owner or manager can edit
CREATE POLICY "notes_update" ON public.notes
FOR UPDATE TO authenticated
USING (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: creator can edit their own
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
-- Team account: users with manage permission
public.has_permission(auth.uid(), account_id, 'notes.manage')
)
WITH CHECK (
-- Same conditions for updates
account_id = (SELECT auth.uid())
OR
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
public.has_permission(auth.uid(), account_id, 'notes.manage')
);
-- DELETE policy: Stricter permissions required
CREATE POLICY "notes_delete" ON public.notes
FOR DELETE TO authenticated
USING (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: creator can delete own notes
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
-- Team account: users with delete permission
public.has_permission(auth.uid(), account_id, 'notes.delete')
);
```
### Automatic Triggers
Add triggers for common patterns:
```sql
-- Automatically update timestamps
CREATE TRIGGER notes_updated_at
BEFORE UPDATE ON public.notes
FOR EACH ROW
EXECUTE FUNCTION kit.trigger_set_timestamps();
-- Track who made changes
CREATE TRIGGER notes_track_changes
BEFORE INSERT OR UPDATE ON public.notes
FOR EACH ROW
EXECUTE FUNCTION kit.trigger_set_user_tracking();
```
## Advanced Patterns
### 1. Hierarchical Notes (Categories)
```sql
-- Note categories with hierarchy
CREATE TABLE IF NOT EXISTS public.note_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
parent_id uuid REFERENCES public.note_categories(id) ON DELETE CASCADE,
name varchar(255) NOT NULL,
color varchar(7), -- hex color codes
path ltree, -- efficient tree operations
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
-- Ensure hierarchy stays within account
CONSTRAINT categories_same_account CHECK (
parent_id IS NULL OR
(SELECT account_id FROM public.note_categories WHERE id = parent_id) = account_id
),
-- Prevent circular references
CONSTRAINT categories_no_self_parent CHECK (id != parent_id)
);
-- Link notes to categories
ALTER TABLE public.notes ADD COLUMN category_id uuid REFERENCES public.note_categories(id) ON DELETE SET NULL;
-- Index for tree operations
CREATE INDEX idx_note_categories_path ON public.note_categories USING gist(path);
CREATE INDEX idx_note_categories_account ON public.note_categories(account_id, parent_id);
```
### 2. Note Sharing and Collaboration
```sql
-- External sharing tokens
CREATE TABLE IF NOT EXISTS public.note_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
note_id uuid NOT NULL REFERENCES public.notes(id) ON DELETE CASCADE,
share_token varchar(64) NOT NULL UNIQUE,
expires_at timestamptz,
password_hash varchar(255), -- optional password protection
view_count integer DEFAULT 0,
max_views integer, -- optional view limit
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
-- Ensure token uniqueness
CONSTRAINT share_token_format CHECK (share_token ~ '^[a-zA-Z0-9_-]{32,64}$')
);
-- Function to generate secure share tokens
CREATE OR REPLACE FUNCTION generate_note_share_token()
RETURNS varchar(64) AS $$
BEGIN
RETURN encode(gen_random_bytes(32), 'base64url');
END;
$$ LANGUAGE plpgsql;
```
### 3. Usage Tracking for Billing
```sql
-- Track note creation for usage-based billing
CREATE TABLE IF NOT EXISTS public.note_usage_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
action varchar(50) NOT NULL, -- 'create', 'share', 'export'
note_count integer DEFAULT 1,
date date DEFAULT CURRENT_DATE,
-- Daily aggregation
UNIQUE(account_id, action, date)
);
-- Function to track note usage
CREATE OR REPLACE FUNCTION track_note_usage(
target_account_id uuid,
usage_action varchar(50)
) RETURNS void AS $$
BEGIN
INSERT INTO public.note_usage_logs (account_id, action, note_count)
VALUES (target_account_id, usage_action, 1)
ON CONFLICT (account_id, action, date)
DO UPDATE SET note_count = note_usage_logs.note_count + 1;
END;
$$ LANGUAGE plpgsql;
-- Trigger to track note creation
CREATE OR REPLACE FUNCTION trigger_track_note_creation()
RETURNS trigger AS $$
BEGIN
PERFORM track_note_usage(NEW.account_id, 'create');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER notes_track_creation
AFTER INSERT ON public.notes
FOR EACH ROW
EXECUTE FUNCTION trigger_track_note_creation();
```
### 4. Feature Access Control
```sql
-- Check if account has access to advanced note features
CREATE OR REPLACE FUNCTION has_advanced_notes_access(target_account_id uuid)
RETURNS boolean AS $$
DECLARE
has_access boolean := false;
BEGIN
-- Check active subscription with advanced features
SELECT EXISTS(
SELECT 1
FROM public.subscriptions s
JOIN public.subscription_items si ON s.id = si.subscription_id
WHERE s.account_id = target_account_id
AND s.status = 'active'
AND si.price_id IN ('price_pro_plan', 'price_enterprise_plan')
) INTO has_access;
RETURN has_access;
END;
$$ LANGUAGE plpgsql;
-- Restrictive policy for advanced features
CREATE POLICY "notes_advanced_features" ON public.notes
AS RESTRICTIVE
FOR ALL TO authenticated
USING (
-- Basic features always allowed
is_published = true
OR category_id IS NULL
OR tags = '{}'
OR
-- Advanced features require subscription
has_advanced_notes_access(account_id)
);
```
## Security Enhancements
### MFA Compliance
For sensitive note operations, enforce MFA:
```sql
-- Require MFA for note deletion
CREATE POLICY "notes_delete_mfa" ON public.notes
AS RESTRICTIVE
FOR DELETE TO authenticated
USING (public.is_mfa_compliant());
```
### Super Admin Access
Allow super admins to access all notes for support purposes:
```sql
-- Super admin read access (for support)
CREATE POLICY "notes_super_admin_access" ON public.notes
FOR SELECT TO authenticated
USING (public.is_super_admin());
```
### Rate Limiting
Implement basic rate limiting for note creation:
```sql
-- Rate limiting: max 100 notes per day per account
CREATE OR REPLACE FUNCTION check_note_creation_limit(target_account_id uuid)
RETURNS boolean AS $$
DECLARE
daily_count integer;
BEGIN
SELECT COALESCE(note_count, 0) INTO daily_count
FROM public.note_usage_logs
WHERE account_id = target_account_id
AND action = 'create'
AND date = CURRENT_DATE;
RETURN daily_count < 100; -- Adjust limit as needed
END;
$$ LANGUAGE plpgsql;
-- Policy to enforce rate limiting
CREATE POLICY "notes_rate_limit" ON public.notes
AS RESTRICTIVE
FOR INSERT TO authenticated
WITH CHECK (check_note_creation_limit(account_id));
```
### Type Generation
After schema changes, always update TypeScript types:
```bash
# reset the database
pnpm supabase:web:reset
# Generate new types
pnpm supabase:web:typegen
# Verify types work in your application
pnpm typecheck
```
## Example Usage in Application
With your schema complete, here's how to use it in your application:
```typescript
// Server component - automatically inherits RLS protection
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function NotesPage({ params }: { params: Promise<{ account: string }> }) {
const { account } = await params;
const client = getSupabaseServerClient();
// RLS automatically filters to accessible notes
const { data: notes } = await client
.from('notes')
.select(`
*,
category:note_categories(name, color),
creator:created_by(name, avatar_url)
`)
.eq('account_id', params.account)
.order('created_at', { ascending: false });
return <NotesList notes={notes} />;
}
```
From the client component, you can use the `useQuery` hook to fetch the notes.
```typescript
// Client component with real-time updates
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function useNotes(accountId: string) {
const supabase = useSupabase();
return useQuery({
queryKey: ['notes', accountId],
queryFn: async () => {
const { data } = await supabase
.from('notes')
.select('*, category:note_categories(name)')
.eq('account_id', accountId);
return data;
}
});
}
```
## Summary
You've now created a comprehensive notes feature that demonstrates:
✅ **Proper multi-tenancy** with account-based data isolation
✅ **Granular permissions** using Makerkit's role system
✅ **Advanced features** like categories, sharing, and usage tracking
✅ **Security best practices** with comprehensive RLS policies
✅ **Performance optimization** with proper indexing
✅ **Integration patterns** with billing and feature gates
This pattern can be adapted for any feature in your SaaS application. Remember to always:
- Start with proper planning and data ownership decisions
- Enable RLS and create comprehensive policies
- Add appropriate indexes for your query patterns
- Test thoroughly before deploying
- Update TypeScript types after schema changes
Your database schema is now production-ready and follows Makerkit's security and architecture best practices!