diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3076352
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,32 @@
+# ---------- SUPABASE ----------
+VITE_SUPABASE_URL=https://your-project.supabase.co
+VITE_SUPABASE_ANON_KEY=your-anon-key-here
+SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
+
+# ---------- EMAIL SERVICE ----------
+RESEND_API_KEY=your-resend-api-key-here
+
+# ---------- CAPTCHA ----------
+VITE_HCAPTCHA_SITE_KEY=458ab136-5512-4228-8256-9a0ee862c176
+HCAPTCHA_SECRET_KEY=ES_36462d6276ff498995c31791e6c8acc2
+
+# ---------- EMAIL SERVICE ----------
+RESEND_API_KEY=your-resend-api-key-here
+
+# ---------- STORAGE BUCKETS ----------
+VITE_STORAGE_BUCKET_ATTACHMENTS=comment-attachments
+VITE_STORAGE_BUCKET_EXPORTS=agency-exports
+VITE_STORAGE_BUCKET_AGENCY_ASSETS=agency-assets
+
+# ---------- APPLICATION ----------
+VITE_SITE_URL=http://localhost:5173
+VITE_EMAIL_FROM=notifications@opencomments.us
+VITE_SUPPORT_EMAIL=support@opencomments.us
+
+# ---------- DEVELOPMENT ----------
+NODE_ENV=development
+VITE_DEBUG_MODE=false
+
+# ---------- OPTIONAL FEATURES ----------
+VITE_ENABLE_ANALYTICS=false
+VITE_MAX_FILE_SIZE_MB=10
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..dd84ea7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md
new file mode 100644
index 0000000..48d5f81
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/custom.md
@@ -0,0 +1,10 @@
+---
+name: Custom issue template
+about: Describe this issue template's purpose here.
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/README.md b/README.md
index 0c46927..868464e 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,141 @@
-opencomments
+# OpenComments
+
+[](https://opensource.org/licenses/MIT)
+[](https://opensource.org/)
+[](https://metaphase.tech)
+[](https://status.opencomments.us)
+[](https://app.netlify.com/sites/your-site-name/deploys)
+[](https://github.com/MetaPhase-Consulting/opencomments/actions)
+[](https://www.w3.org/WAI/WCAG21/quickref/)
+[](https://supabase.com/security)
+[](https://status.opencomments.us)
+[](https://github.com/MetaPhase-Consulting/opencomments/releases)
+
+# OpenComments
+
+OpenComments is a modern, accessible public commenting platform that enables transparent government by making it easy for agencies to collect, moderate, and publish public feedback on policies and proposals.
+
+## ๐๏ธ Project Purpose
+
+OpenComments bridges the gap between government agencies and citizens by providing a secure, user-friendly platform for public comment periods. Built with modern web technologies and designed for accessibility, it ensures every voice can be heard in the democratic process.
+
+## ๐ ๏ธ Tech Stack
+
+- **Frontend**: React 18 + TypeScript + Tailwind CSS
+- **Backend**: Supabase (PostgreSQL + Auth + Storage + Edge Functions)
+- **Testing**: Vitest + Cypress + Playwright
+- **Deployment**: Netlify (Frontend) + Supabase (Backend)
+- **CI/CD**: GitHub Actions
+
+## ๐ Quick Start
+
+### Prerequisites
+- Node.js 18+
+- npm or yarn
+- Supabase account
+
+### Setup
+```bash
+# Clone repository
+git clone https://github.com/your-org/opencomments.git
+cd opencomments
+
+# Install dependencies
+npm install
+
+# Copy environment template
+cp .env.example .env
+
+# Configure your Supabase credentials in .env
+# VITE_SUPABASE_URL=https://your-project.supabase.co
+# VITE_SUPABASE_ANON_KEY=your-anon-key
+
+# Apply database migrations
+npm run db:migrate
+
+# Start development server
+npm run dev
+```
+
+Visit `http://localhost:5173` to see the application.
+
+## ๐ Folder Structure
+
+```
+opencomments/
+โโโ src/
+โ โโโ components/ # Reusable UI components
+โ โโโ pages/ # Route components
+โ โ โโโ agency/ # Agency admin portal
+โ โ โโโ public/ # Public-facing pages
+โ โโโ hooks/ # Custom React hooks
+โ โโโ contexts/ # React context providers
+โ โโโ lib/ # Utilities and configurations
+โ โโโ types/ # TypeScript type definitions
+โโโ supabase/
+โ โโโ migrations/ # Database schema changes
+โ โโโ functions/ # Edge functions
+โโโ tests/ # Test files
+โโโ docs/ # Documentation
+โโโ public/ # Static assets
+```
+
+## ๐งช Development
+
+### Testing
+```bash
+# Run unit tests
+npm run test
+
+# Run E2E tests
+npm run cypress:run
+
+# Run accessibility tests
+npm run test:a11y
+```
+
+### Database
+```bash
+# Apply migrations
+npm run db:migrate
+
+# Reset database (development only)
+supabase db reset
+```
+
+### Quality Assurance
+Before deploying, ensure all tests pass:
+1. Unit tests: `npm run test`
+2. E2E tests: `npm run cypress:run`
+3. Accessibility: `npm run test:a11y`
+4. Manual QA: Follow `QA_CHECKLIST.md`
+
+## ๐ Documentation
+
+- **[DEVELOPER.md](docs/DEVELOPER.md)** - Development setup and workflows
+- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System design and technical overview
+- **[DATAMODEL.md](docs/DATAMODEL.md)** - Database schema and relationships
+- **[AGENCY_ADMIN_GUIDE.md](docs/AGENCY_ADMIN_GUIDE.md)** - Guide for government staff
+- **[PUBLIC_USER_GUIDE.md](docs/PUBLIC_USER_GUIDE.md)** - Guide for citizens
+
+## ๐ค Contributing
+
+1. Fork the repository
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+## ๐ License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## ๐ Support
+
+- **Documentation**: Check the `docs/` folder for detailed guides
+- **Issues**: Report bugs via GitHub Issues
+- **Email**: [support@opencomments.us](mailto:support@opencomments.us)
+
+---
+
+**Built with โค๏ธ for transparent government**
\ No newline at end of file
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..034e848
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,21 @@
+# Security Policy
+
+## Supported Versions
+
+Use this section to tell people about which versions of your project are
+currently being supported with security updates.
+
+| Version | Supported |
+| ------- | ------------------ |
+| 5.1.x | :white_check_mark: |
+| 5.0.x | :x: |
+| 4.0.x | :white_check_mark: |
+| < 4.0 | :x: |
+
+## Reporting a Vulnerability
+
+Use this section to tell people how to report a vulnerability.
+
+Tell them where to go, how often they can expect to get an update on a
+reported vulnerability, what to expect if the vulnerability is accepted or
+declined, etc.
diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md
new file mode 100644
index 0000000..dbea667
--- /dev/null
+++ b/docs/DATAMODEL.md
@@ -0,0 +1,478 @@
+# DATA MODEL GUIDE
+
+This document describes the complete database schema, relationships, and data flow patterns used in OpenComments.
+
+## ๐๏ธ Database Overview
+
+OpenComments uses PostgreSQL with Supabase's Row Level Security (RLS) to ensure multi-tenant data isolation and security. Every table implements soft deletes and audit trails for compliance.
+
+## ๐ Core Entities
+
+### User Management
+
+**profiles**
+```sql
+id uuid PRIMARY KEY (references auth.users)
+email text UNIQUE NOT NULL
+role text CHECK (role IN ('public', 'agency'))
+full_name text
+agency_name text
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+*User profiles extending Supabase auth with role information*
+
+**agencies**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+name text NOT NULL
+jurisdiction text
+description text
+logo_url text
+settings jsonb DEFAULT '{}'
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+*Government agencies that manage comment periods*
+
+**agency_members**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+agency_id uuid REFERENCES agencies(id)
+user_id uuid REFERENCES profiles(id)
+role agency_role DEFAULT 'reviewer'
+invited_by uuid REFERENCES profiles(id)
+joined_at timestamptz DEFAULT now()
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+*Many-to-many relationship between users and agencies with roles*
+
+### Comment System
+
+**dockets**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+agency_id uuid REFERENCES agencies(id)
+title text NOT NULL
+description text NOT NULL
+summary text
+slug text UNIQUE
+reference_code text
+tags text[] DEFAULT '{}'
+status docket_status DEFAULT 'draft'
+comment_deadline timestamptz NOT NULL
+open_at timestamptz
+close_at timestamptz
+settings jsonb DEFAULT '{}'
+auto_publish boolean DEFAULT false
+require_captcha boolean DEFAULT true
+max_file_size_mb integer DEFAULT 10
+allowed_file_types text[] DEFAULT ARRAY['pdf','docx','jpg','png']
+search_vector tsvector
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+*Comment periods/dockets managed by agencies*
+
+**comments**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+docket_id uuid REFERENCES dockets(id)
+user_id uuid REFERENCES profiles(id)
+content text NOT NULL
+status comment_status DEFAULT 'submitted'
+commenter_name text
+commenter_email text
+commenter_organization text
+oauth_provider text
+oauth_uid text
+geo_country text
+content_hash text
+captcha_token text
+ip_address inet
+user_agent text
+search_vector tsvector
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+*Public comments submitted on dockets*
+
+**commenter_info**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+comment_id uuid REFERENCES comments(id)
+representation text CHECK (representation IN ('myself', 'organization', 'behalf_of_another'))
+organization_name text
+authorization_statement text
+perjury_certified boolean NOT NULL DEFAULT false
+certification_timestamp timestamptz DEFAULT now()
+created_at timestamptz DEFAULT now()
+```
+*Commenter representation and legal certification data*
+
+**comment_rate_limits**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+user_id uuid
+ip_address inet
+docket_id uuid REFERENCES dockets(id)
+submission_count integer DEFAULT 1
+last_submission timestamptz DEFAULT now()
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+*Anti-spam rate limiting tracking*
+### File Management
+
+**comment_attachments**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+comment_id uuid REFERENCES comments(id)
+filename text NOT NULL
+file_url text NOT NULL
+file_path text NOT NULL
+mime_type text NOT NULL
+file_size bigint NOT NULL
+created_at timestamptz DEFAULT now()
+```
+*Files uploaded with comments*
+
+**docket_attachments**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+docket_id uuid REFERENCES dockets(id)
+filename text NOT NULL
+file_url text NOT NULL
+file_size bigint NOT NULL
+mime_type text NOT NULL
+uploaded_by uuid REFERENCES profiles(id)
+created_at timestamptz DEFAULT now()
+```
+*Supporting documents for dockets*
+
+## ๐ Relationships
+
+### Entity Relationship Diagram
+
+```
+agencies โโโฌโโ agency_members โโโโ profiles
+ โ
+ โโโ dockets โโโฌโโ comments โโโโ comment_attachments
+ โ
+ โโโ docket_attachments
+```
+
+### Key Relationships
+
+**One-to-Many**
+- Agency โ Dockets (one agency has many dockets)
+- Docket โ Comments (one docket has many comments)
+- Comment โ Attachments (one comment has many attachments)
+
+**Many-to-Many**
+- Users โ Agencies (via agency_members with roles)
+
+**Self-Referencing**
+- agency_members.invited_by โ profiles.id
+
+## ๐ Row Level Security (RLS)
+
+### Security Principles
+
+**Agency Isolation**
+- Users can only access data from agencies they belong to
+- Public users can only see published comments on open dockets
+- No cross-agency data leakage
+
+**Role-Based Access**
+- Viewer: Read-only access to agency data
+- Reviewer: Can moderate comments
+- Manager: Can create and manage dockets
+- Admin: Can manage users and settings
+- Owner: Full agency control
+
+### RLS Policy Examples
+
+**Dockets Table**
+```sql
+-- Agency members can read dockets
+CREATE POLICY "Agency members can read dockets" ON dockets
+FOR SELECT TO authenticated
+USING (EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_id = dockets.agency_id
+ AND user_id = auth.uid()
+));
+
+-- Public can read open dockets
+CREATE POLICY "Public can read open dockets" ON dockets
+FOR SELECT TO anon, authenticated
+USING (status = 'open');
+```
+
+**Comments Table**
+```sql
+-- Users can read own comments
+CREATE POLICY "Users can read own comments" ON comments
+FOR SELECT TO authenticated
+USING (auth.uid() = user_id);
+
+-- Agency members can read comments on agency dockets
+CREATE POLICY "Agency members can read comments" ON comments
+FOR SELECT TO authenticated
+USING (EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = comments.docket_id
+ AND am.user_id = auth.uid()
+));
+```
+
+## ๐ Data Types & Enums
+
+### Custom Types
+
+**agency_role**
+```sql
+CREATE TYPE agency_role AS ENUM (
+ 'owner',
+ 'admin',
+ 'manager',
+ 'reviewer',
+ 'viewer'
+);
+```
+
+**comment_status**
+```sql
+CREATE TYPE comment_status AS ENUM (
+ 'submitted',
+ 'under_review',
+ 'published',
+ 'rejected',
+ 'flagged'
+);
+```
+
+**docket_status**
+```sql
+CREATE TYPE docket_status AS ENUM (
+ 'draft',
+ 'open',
+ 'closed',
+ 'archived'
+);
+```
+
+## ๐ Search & Indexing
+
+### Full-Text Search
+
+**Search Vectors**
+- `dockets.search_vector`: Title + description + tags
+- `comments.search_vector`: Content + commenter info
+
+**Indexes**
+```sql
+-- GIN indexes for full-text search
+CREATE INDEX idx_dockets_search_vector ON dockets USING gin(search_vector);
+CREATE INDEX idx_comments_search_vector ON comments USING gin(search_vector);
+
+-- Performance indexes
+CREATE INDEX idx_comments_docket_id ON comments(docket_id);
+CREATE INDEX idx_comments_status ON comments(status);
+CREATE INDEX idx_dockets_agency_id ON dockets(agency_id);
+CREATE INDEX idx_dockets_status ON dockets(status);
+```
+
+### Search Functions
+
+**Update Search Vectors**
+```sql
+CREATE OR REPLACE FUNCTION update_docket_search_vector()
+RETURNS trigger AS $$
+BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
+ setweight(to_tsvector('english', array_to_string(NEW.tags, ' ')), 'C');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+```
+
+## ๐ Analytics & Reporting
+
+### Materialized Views
+
+**Agency Statistics**
+```sql
+CREATE MATERIALIZED VIEW agency_stats AS
+SELECT
+ a.id as agency_id,
+ a.name as agency_name,
+ COUNT(DISTINCT d.id) as total_dockets,
+ COUNT(DISTINCT CASE WHEN d.status = 'open' THEN d.id END) as open_dockets,
+ COUNT(DISTINCT c.id) as total_comments,
+ COUNT(DISTINCT CASE WHEN c.status = 'published' THEN c.id END) as published_comments
+FROM agencies a
+LEFT JOIN dockets d ON d.agency_id = a.id
+LEFT JOIN comments c ON c.docket_id = d.id
+WHERE a.deleted_at IS NULL
+GROUP BY a.id, a.name;
+```
+
+### Export Tables
+
+**exports**
+```sql
+id uuid PRIMARY KEY DEFAULT gen_random_uuid()
+agency_id uuid REFERENCES agencies(id)
+docket_id uuid REFERENCES dockets(id) -- NULL for multi-docket exports
+export_type export_type NOT NULL
+filters_json jsonb
+file_url text
+file_path text
+size_bytes bigint DEFAULT 0
+status export_status DEFAULT 'pending'
+progress_percent integer DEFAULT 0
+error_message text
+expires_at timestamptz DEFAULT (now() + interval '24 hours')
+created_by uuid REFERENCES profiles(id)
+created_at timestamptz DEFAULT now()
+updated_at timestamptz DEFAULT now()
+```
+
+## ๐ Data Flow Patterns
+
+### Comment Submission Flow
+
+1. **Public Submission**
+ - User submits comment via public form
+ - Comment stored with status 'submitted'
+ - Files uploaded to comment_attachments bucket
+ - Search vector generated automatically
+
+2. **Moderation Process**
+ - Agency staff reviews in moderation queue
+ - Status updated to 'published', 'rejected', or 'flagged'
+ - Moderation actions logged for audit
+
+3. **Public Display**
+ - Only 'published' comments visible to public
+ - Comments displayed with docket information
+ - Attachments available via signed URLs
+
+### Export Generation Flow
+
+1. **Export Request**
+ - User initiates export from UI
+ - Export job created with 'pending' status
+ - Background function triggered
+
+2. **Processing**
+ - Edge function queries database
+ - CSV generated with comment data
+ - ZIP created with attachment files
+ - Files uploaded to exports bucket
+
+3. **Completion**
+ - Export status updated to 'completed'
+ - Download URL generated with expiry
+ - User notified of completion
+
+## ๐ก๏ธ Data Integrity
+
+### Constraints
+
+**Foreign Key Constraints**
+- All references properly constrained
+- Cascade deletes where appropriate
+- Prevent orphaned records
+
+**Check Constraints**
+- Enum values enforced at database level
+- File size limits validated
+- Email format validation
+
+### Triggers
+
+**Automatic Timestamps**
+```sql
+CREATE TRIGGER update_updated_at_column
+BEFORE UPDATE ON table_name
+FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+```
+
+**Search Vector Updates**
+```sql
+CREATE TRIGGER trigger_update_docket_search_vector
+BEFORE INSERT OR UPDATE ON dockets
+FOR EACH ROW EXECUTE FUNCTION update_docket_search_vector();
+```
+
+## ๐ Migration Strategy
+
+### Schema Evolution
+
+**Migration Principles**
+- Never modify existing migrations
+- Always create new migration files
+- Include rollback instructions
+- Test on sample data first
+
+**Migration Template**
+```sql
+/*
+ # Migration Title
+
+ 1. New Tables
+ - table_name: description
+
+ 2. Changes
+ - Modified columns
+ - New indexes
+
+ 3. Security
+ - RLS policies
+ - Permission updates
+*/
+
+-- Migration SQL here
+```
+
+### Data Migration
+
+**Large Data Changes**
+- Use batched updates for large tables
+- Monitor performance during migration
+- Plan for downtime if necessary
+- Have rollback plan ready
+
+## ๐ Performance Considerations
+
+### Query Optimization
+
+**Efficient Queries**
+- Use appropriate indexes
+- Avoid N+1 query problems
+- Implement pagination for large datasets
+- Use materialized views for complex aggregations
+
+**Connection Management**
+- Connection pooling enabled
+- Query timeout limits
+- Resource usage monitoring
+
+### Storage Optimization
+
+**File Storage**
+- Automatic compression for exports
+- CDN for global file distribution
+- Lifecycle policies for old files
+- Storage usage monitoring
+
+---
+
+**See also**: [ARCHITECTURE.md](ARCHITECTURE.md), [DEVELOPER.md](DEVELOPER.md)
\ No newline at end of file
diff --git a/docs/PUBLIC_USER_GUIDE.md b/docs/PUBLIC_USER_GUIDE.md
new file mode 100644
index 0000000..92afd51
--- /dev/null
+++ b/docs/PUBLIC_USER_GUIDE.md
@@ -0,0 +1,326 @@
+# PUBLIC USER GUIDE
+
+This guide helps citizens effectively use OpenComments to find, read, and submit comments on government proposals and policy changes.
+
+## ๐๏ธ Welcome to OpenComments
+
+OpenComments makes it easy for you to participate in government decision-making by providing a simple, accessible way to submit comments on policies, regulations, and proposals that affect your community.
+
+## ๐ Finding Comment Opportunities
+
+### Browse by Location
+
+1. **Visit OpenComments**
+ - Go to [opencomments.us](https://opencomments.us)
+ - Use the interactive state map to find your state
+ - Or select your state from the dropdown menu
+
+2. **Local Government Portals**
+ - Each agency has its own portal (e.g., `springfield.opencomments.us`)
+ - Bookmark your local government's portal for easy access
+ - Sign up for notifications about new comment periods
+
+### Search for Topics
+
+1. **Use the Search Bar**
+ - Enter keywords related to your interests
+ - Search for specific topics like "housing," "transportation," or "environment"
+ - Use the advanced search for more specific results
+
+2. **Browse by Category**
+ - Filter by topic tags (Budget, Transportation, Housing, etc.)
+ - View currently open comment periods
+ - See recently closed periods for reference
+
+## ๐ Submitting Comments
+
+### Creating an Account
+
+1. **Sign Up Process**
+ - Click "Public Login" on any OpenComments site
+ - Choose from three options:
+ - **Continue with Email**: Traditional email/password signup
+ - **Continue with GitHub**: Use your GitHub account
+ - **Continue with Google**: Use your Google account
+ - For email signup, verify your email address before commenting
+
+2. **Account Benefits**
+ - Track your submitted comments
+ - Save dockets you're interested in
+ - Receive notifications about comment deadlines
+ - Secure OAuth authentication options
+
+### Writing Effective Comments
+
+1. **Before You Write**
+ - Read the proposal or document thoroughly
+ - Review any supporting materials provided
+ - Check if similar comments have already been submitted
+
+2. **Comment Structure**
+ - **Introduction**: State your position clearly
+ - **Reasoning**: Explain why you support or oppose the proposal
+ - **Evidence**: Include facts, personal experience, or expert knowledge
+ - **Conclusion**: Summarize your main points
+
+3. **Writing Tips**
+ - Be specific and factual
+ - Stay on topic and relevant to the proposal
+ - Use respectful, professional language
+ - Avoid personal attacks or inflammatory language
+
+### Submitting Your Comment
+
+1. **Fill Out the Form**
+ - **Name**: Your full name (required for public record)
+ - **Email**: Contact information (not displayed publicly)
+ - **Organization**: If representing a group (optional)
+ - **Comment**: Your detailed feedback
+
+2. **Adding Attachments**
+ - Upload supporting documents (PDF, Word, images)
+ - Maximum file size varies by agency (typically 10MB)
+ - Include relevant photos, charts, or research
+ - Note: Some dockets may not allow file uploads
+
+3. **Representation & Certification**
+ - Specify who you're representing (yourself, organization, or another entity)
+ - Provide organization details if applicable
+ - **Legal Certification**: You must certify under penalty of perjury that your information is accurate
+
+4. **Review and Submit**
+ - Proofread your comment carefully
+ - Complete the CAPTCHA verification
+ - Acknowledge that your comment will be public
+ - Click "Submit Comment"
+ - Save the confirmation number for your records
+
+## ๐ Understanding the Process
+
+### Comment Status
+
+**Submitted**
+- Your comment has been received
+- It's in the queue for agency review
+- You'll receive a confirmation email
+
+**Under Review**
+- Agency staff are reviewing your comment
+- This ensures comments meet basic guidelines
+- Review typically takes 1-3 business days
+
+**Published**
+- Your comment is now visible on the public site
+- It's part of the official public record
+- Others can read and reference your feedback
+
+**Note**: Comments may be rejected if they contain inappropriate content, are off-topic, or violate comment guidelines. You'll be notified if this occurs.
+
+### Timeline Expectations
+
+1. **Submission**: Immediate confirmation
+2. **Review**: 1-3 business days for moderation
+3. **Publication**: Appears publicly after approval
+4. **Agency Response**: Varies by agency and proposal type
+
+## ๐ Privacy & Public Records
+
+### What's Public
+
+**Publicly Visible Information**
+- Your name
+- Your comment text
+- Organization affiliation (if provided)
+- Submission date
+- Any attachments you upload
+
+**Not Publicly Visible**
+- Your email address
+- Your IP address
+- Personal contact information
+
+### Your Rights
+
+**Access Your Data**
+- View all your submitted comments in your dashboard
+- Download copies of your submissions
+- Request corrections to your information
+
+**Data Protection**
+- Your personal information is protected according to government privacy standards
+- Email addresses are never shared or sold
+- You can request account deletion (subject to public record requirements)
+
+## ๐ Tracking Your Participation
+
+### Your Dashboard
+
+1. **Comment History**
+ - View all comments you've submitted
+ - See the status of each comment
+ - Track which have been published
+
+2. **Saved Dockets**
+ - Bookmark comment periods you're interested in
+ - Get reminders about upcoming deadlines
+ - Follow topics relevant to you
+
+3. **Notifications**
+ - Email alerts for new comment periods
+ - Deadline reminders for saved dockets
+ - Updates on your comment status
+
+## ๐ก Tips for Effective Participation
+
+### Research and Preparation
+
+1. **Understand the Proposal**
+ - Read all provided documents carefully
+ - Research the background and context
+ - Understand the potential impacts
+
+2. **Know the Process**
+ - Learn how your government makes decisions
+ - Understand how public comments are used
+ - Know who the decision-makers are
+
+### Writing Impactful Comments
+
+1. **Be Specific**
+ - Reference specific sections of proposals
+ - Provide concrete examples and evidence
+ - Suggest specific alternatives or improvements
+
+2. **Share Your Expertise**
+ - Include relevant professional experience
+ - Share local knowledge and community insights
+ - Provide data or research to support your points
+
+3. **Tell Your Story**
+ - Explain how the proposal would affect you personally
+ - Share community impacts you've observed
+ - Connect policy to real-world consequences
+
+### Engaging Constructively
+
+1. **Stay Professional**
+ - Use respectful language even when disagreeing
+ - Focus on issues, not personalities
+ - Acknowledge valid points from other perspectives
+
+2. **Be Original**
+ - Avoid copying form letters or templates
+ - Add your unique perspective and experience
+ - Build on others' comments rather than repeating them
+
+## โ Frequently Asked Questions
+
+### Submitting Comments
+
+**Q: Do I need to create an account to comment?**
+A: Yes, all comments require authentication. You can use email/password, GitHub, or Google to sign in.
+
+**Q: What is the perjury certification requirement?**
+A: You must certify under penalty of perjury that your information is accurate and you're authorized to submit the comment. This ensures the integrity of the public comment process.
+
+**Q: Can I submit multiple comments on the same proposal?**
+A: Yes, but there may be limits (typically 3 comments per person per docket). Each comment should address different aspects or provide new information.
+
+**Q: What if I make a mistake in my comment?**
+A: Contact the agency directly to request corrections. You cannot edit comments after submission.
+
+**Q: Can I submit comments without providing my name?**
+A: While you can leave the name field blank, providing your name gives more weight to your comment in the public record.
+
+### Technical Issues
+
+**Q: Why isn't my comment showing up publicly?**
+A: Comments must be reviewed and approved before appearing publicly. This typically takes 1-3 business days.
+
+**Q: I'm having trouble uploading a file. What should I do?**
+A: Check that your file is under the size limit and in an allowed format (PDF, Word, images). Try a smaller file or different format.
+
+**Q: The comment period shows as closed, but I want to submit feedback.**
+A: Contact the agency directly. They may still accept late comments or have other ways to provide input.
+
+### Process Questions
+
+**Q: How are public comments used in decision-making?**
+A: Agencies are required to consider all public comments. They often publish response documents addressing common themes and concerns.
+
+**Q: Will I get a response to my specific comment?**
+A: Individual responses are rare, but agencies often publish summary responses addressing common themes from all comments.
+
+**Q: What happens after the comment period closes?**
+A: The agency reviews all comments, may make revisions to proposals, and proceeds with their decision-making process.
+
+### Account Management
+
+**Q: How do I change my email address?**
+A: Log into your account and update your profile information, or contact support for assistance.
+
+**Q: Can I delete my account?**
+A: Yes, but your published comments will remain part of the public record as required by law.
+
+**Q: I forgot my password or want to change my login method.**
+A: Use the "Forgot Password" link for email accounts, or simply sign in with a different OAuth provider (GitHub/Google) using the same email address.
+
+## ๐ Getting Help
+
+### Support Options
+
+1. **Help Center**
+ - Browse frequently asked questions
+ - Access step-by-step guides
+ - Find contact information for specific agencies
+
+2. **Technical Support**
+ - Email: [support@opencomments.us](mailto:support@opencomments.us)
+ - Response time: 1-2 business days
+ - Include details about any error messages
+
+3. **Agency Contact**
+ - Each agency has contact information on their portal
+ - Reach out directly for policy questions
+ - Ask about upcoming comment opportunities
+
+### Accessibility Support
+
+**Need Assistance?**
+- Large print versions of documents available
+- Screen reader compatible interface
+- Alternative formats available upon request
+- Contact [accessibility@opencomments.us](mailto:accessibility@opencomments.us)
+
+**Technical Requirements**
+- Works with all modern web browsers
+- Mobile-friendly design
+- Keyboard navigation supported
+- Compatible with assistive technologies
+
+## ๐ Making a Difference
+
+### Your Voice Matters
+
+Public comments are a vital part of democratic governance. Your participation helps ensure that government decisions reflect community needs and values.
+
+### Stay Engaged
+
+1. **Follow Up**
+ - Monitor how your comments are addressed
+ - Attend public meetings when possible
+ - Continue participating in future comment periods
+
+2. **Spread the Word**
+ - Share comment opportunities with friends and neighbors
+ - Encourage others to participate
+ - Help build a more engaged community
+
+3. **Stay Informed**
+ - Subscribe to agency newsletters
+ - Follow local government social media
+ - Attend public meetings and hearings
+
+---
+
+**See also**: [AGENCY_ADMIN_GUIDE.md](AGENCY_ADMIN_GUIDE.md), [ACCESSIBILITY.md](ACCESSIBILITY.md)
\ No newline at end of file
diff --git a/index.html b/index.html
index 50730eb..e68a606 100644
--- a/index.html
+++ b/index.html
@@ -2,11 +2,15 @@
+ Are you sure you want to {pendingStatusChange} this comment window?
+ {pendingStatusChange === 'closed' && ' This will prevent new submissions.'}
+ {pendingStatusChange === 'archived' && ' This action cannot be undone.'}
+
+ {searchQuery || statusFilter !== 'all'
+ ? 'Try adjusting your search or filter criteria.'
+ : 'Create your first comment window to start collecting public input.'
+ }
+
+ {selectedComment
+ ? 'Please provide a reason for rejecting this comment:'
+ : `Please provide a reason for rejecting these ${selectedComments.size} comments:`
+ }
+
+ {statusAction === 'activate' ? 'Activate' : 'Deactivate'} User
+
+
+
+ Are you sure you want to {statusAction} {selectedUser.full_name || selectedUser.email}?
+ {statusAction === 'deactivate' && ' This will prevent them from accessing the agency portal.'}
+
+ {filters.query || (filters.tags && filters.tags.length > 0)
+ ? 'Try adjusting your search terms or filters.'
+ : 'There are currently no open comment periods.'}
+
+ Track your comments and get notified about new comment opportunities.
+
+
+ Sign up for free
+
+
+
+
+
+
Follow Up
+
+ Check back to see how your comment and others influenced the final decision.
+
+
+ Browse all dockets
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ThankYou;
\ No newline at end of file
diff --git a/src/types/roles.ts b/src/types/roles.ts
new file mode 100644
index 0000000..55fd6e4
--- /dev/null
+++ b/src/types/roles.ts
@@ -0,0 +1,184 @@
+// Role definitions and permission system for Agency Admin area
+
+export type AgencyRole = 'owner' | 'admin' | 'manager' | 'reviewer' | 'viewer'
+
+export const AGENCY_ROLES: Record = {
+ owner: {
+ name: 'Owner',
+ description: 'Agency executive / principal contact',
+ level: 5
+ },
+ admin: {
+ name: 'Admin',
+ description: 'IT lead or program manager',
+ level: 4
+ },
+ manager: {
+ name: 'Manager',
+ description: 'Program staff who run comment periods',
+ level: 3
+ },
+ reviewer: {
+ name: 'Reviewer',
+ description: 'Clerk or analyst who reviews submissions',
+ level: 2
+ },
+ viewer: {
+ name: 'Viewer',
+ description: 'Read-only staff, auditors',
+ level: 1
+ }
+} as const
+
+// Permission actions that can be performed
+export type Permission =
+ | 'view_dashboard'
+ | 'create_thread'
+ | 'edit_thread'
+ | 'close_thread'
+ | 'archive_thread'
+ | 'approve_comments'
+ | 'reject_comments'
+ | 'flag_comments'
+ | 'bulk_export'
+ | 'invite_users'
+ | 'remove_users'
+ | 'change_user_roles'
+ | 'edit_agency_settings'
+ | 'transfer_ownership'
+ | 'archive_agency'
+ | 'delete_agency'
+ | 'view_analytics'
+ | 'manage_moderation_queue'
+
+// Permission matrix - defines what each role can do
+export const PERMISSION_MATRIX: Record = {
+ owner: [
+ 'view_dashboard',
+ 'create_thread',
+ 'edit_thread',
+ 'close_thread',
+ 'archive_thread',
+ 'approve_comments',
+ 'reject_comments',
+ 'flag_comments',
+ 'bulk_export',
+ 'invite_users',
+ 'remove_users',
+ 'change_user_roles',
+ 'edit_agency_settings',
+ 'transfer_ownership',
+ 'archive_agency',
+ 'delete_agency',
+ 'view_analytics',
+ 'manage_moderation_queue'
+ ],
+ admin: [
+ 'view_dashboard',
+ 'create_thread',
+ 'edit_thread',
+ 'close_thread',
+ 'archive_thread',
+ 'approve_comments',
+ 'reject_comments',
+ 'flag_comments',
+ 'bulk_export',
+ 'invite_users',
+ 'remove_users',
+ 'change_user_roles', // Limited to Manager level and below
+ 'edit_agency_settings',
+ 'view_analytics',
+ 'manage_moderation_queue'
+ ],
+ manager: [
+ 'view_dashboard',
+ 'create_thread',
+ 'edit_thread', // Only threads they own
+ 'close_thread', // Only threads they own
+ 'archive_thread', // Only threads they own
+ 'approve_comments',
+ 'reject_comments',
+ 'flag_comments',
+ 'bulk_export',
+ 'view_analytics',
+ 'manage_moderation_queue'
+ ],
+ reviewer: [
+ 'view_dashboard',
+ 'approve_comments',
+ 'reject_comments',
+ 'flag_comments',
+ 'bulk_export'
+ ],
+ viewer: [
+ 'view_dashboard',
+ 'bulk_export'
+ ]
+}
+
+// Helper functions for permission checking
+export const hasPermission = (role: AgencyRole, permission: Permission): boolean => {
+ return PERMISSION_MATRIX[role].includes(permission)
+}
+
+export const canManageRole = (userRole: AgencyRole, targetRole: AgencyRole): boolean => {
+ const userLevel = AGENCY_ROLES[userRole].level
+ const targetLevel = AGENCY_ROLES[targetRole].level
+
+ // Owners can manage anyone
+ if (userRole === 'owner') return true
+
+ // Admins can manage up to Manager level
+ if (userRole === 'admin' && targetLevel <= AGENCY_ROLES.manager.level) return true
+
+ return false
+}
+
+export const getRoleHierarchy = (): AgencyRole[] => {
+ return Object.keys(AGENCY_ROLES)
+ .sort((a, b) => AGENCY_ROLES[b as AgencyRole].level - AGENCY_ROLES[a as AgencyRole].level) as AgencyRole[]
+}
+
+export const isHigherRole = (role1: AgencyRole, role2: AgencyRole): boolean => {
+ return AGENCY_ROLES[role1].level > AGENCY_ROLES[role2].level
+}
+
+// UI helper functions
+export const getPermissionTooltip = (permission: Permission, userRole: AgencyRole): string => {
+ if (hasPermission(userRole, permission)) return ''
+
+ const requiredRoles = Object.entries(PERMISSION_MATRIX)
+ .filter(([_, permissions]) => permissions.includes(permission))
+ .map(([role, _]) => AGENCY_ROLES[role as AgencyRole].name)
+ .sort((a, b) => {
+ const roleA = Object.keys(AGENCY_ROLES).find(r => AGENCY_ROLES[r as AgencyRole].name === a) as AgencyRole
+ const roleB = Object.keys(AGENCY_ROLES).find(r => AGENCY_ROLES[r as AgencyRole].name === b) as AgencyRole
+ return AGENCY_ROLES[roleA].level - AGENCY_ROLES[roleB].level
+ })
+
+ const lowestRole = requiredRoles[0]
+ return `Requires ${lowestRole} role or higher`
+}
+
+// Agency membership interface
+export interface AgencyMembership {
+ agency_id: string
+ agency_name: string
+ role: AgencyRole
+ joined_at: string
+ invited_by?: string
+}
+
+// Extended user profile with agency memberships
+export interface AgencyUserProfile {
+ id: string
+ email: string
+ full_name?: string
+ memberships: AgencyMembership[]
+ created_at: string
+ updated_at: string
+}
\ No newline at end of file
diff --git a/supabase/functions/generate-export/index.ts b/supabase/functions/generate-export/index.ts
new file mode 100644
index 0000000..cbad619
--- /dev/null
+++ b/supabase/functions/generate-export/index.ts
@@ -0,0 +1,249 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+interface ExportRequest {
+ export_id: string
+}
+
+serve(async (req) => {
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders })
+ }
+
+ try {
+ const supabase = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
+ )
+
+ const { export_id }: ExportRequest = await req.json()
+
+ // Get export job details
+ const { data: exportJob, error: exportError } = await supabase
+ .from('exports')
+ .select('*')
+ .eq('id', export_id)
+ .single()
+
+ if (exportError || !exportJob) {
+ throw new Error('Export job not found')
+ }
+
+ // Update status to processing
+ await supabase.rpc('update_export_progress', {
+ p_export_id: export_id,
+ p_status: 'processing',
+ p_progress_percent: 0
+ })
+
+ const { agency_id, docket_id, export_type, filters_json } = exportJob
+ const filters = filters_json || {}
+
+ let fileContent: Uint8Array
+ let fileName: string
+ let contentType: string
+
+ if (export_type === 'csv') {
+ // Generate CSV export
+ const result = await generateCSVExport(supabase, agency_id, docket_id, filters)
+ fileContent = new TextEncoder().encode(result.csv)
+ fileName = `comments_export_${new Date().toISOString().split('T')[0]}.csv`
+ contentType = 'text/csv'
+ } else if (export_type === 'zip') {
+ // Generate ZIP export
+ const result = await generateZIPExport(supabase, agency_id, docket_id, filters)
+ fileContent = result.zipBuffer
+ fileName = `attachments_export_${new Date().toISOString().split('T')[0]}.zip`
+ contentType = 'application/zip'
+ } else if (export_type === 'combined') {
+ // Generate combined export
+ const result = await generateCombinedExport(supabase, agency_id, docket_id, filters)
+ fileContent = result.zipBuffer
+ fileName = `combined_export_${new Date().toISOString().split('T')[0]}.zip`
+ contentType = 'application/zip'
+ } else {
+ throw new Error('Invalid export type')
+ }
+
+ // Upload to storage
+ const filePath = `${agency_id}/${export_id}/${fileName}`
+
+ const { error: uploadError } = await supabase.storage
+ .from('agency-exports')
+ .upload(filePath, fileContent, {
+ contentType,
+ cacheControl: '3600'
+ })
+
+ if (uploadError) {
+ throw new Error(`Upload failed: ${uploadError.message}`)
+ }
+
+ // Get signed URL
+ const { data: urlData } = supabase.storage
+ .from('agency-exports')
+ .getPublicUrl(filePath)
+
+ // Update export job with completion
+ await supabase.rpc('update_export_progress', {
+ p_export_id: export_id,
+ p_status: 'completed',
+ p_progress_percent: 100,
+ p_file_path: filePath,
+ p_file_url: urlData.publicUrl,
+ p_size_bytes: fileContent.length
+ })
+
+ return new Response(
+ JSON.stringify({ success: true, file_url: urlData.publicUrl }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 200
+ }
+ )
+
+ } catch (error) {
+ console.error('Export generation failed:', error)
+
+ // Update export job with error
+ if (req.body) {
+ try {
+ const { export_id } = await req.json()
+ const supabase = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
+ )
+
+ await supabase.rpc('update_export_progress', {
+ p_export_id: export_id,
+ p_status: 'failed',
+ p_error_message: error.message
+ })
+ } catch (updateError) {
+ console.error('Failed to update export status:', updateError)
+ }
+ }
+
+ return new Response(
+ JSON.stringify({ error: error.message }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 500
+ }
+ )
+ }
+})
+
+async function generateCSVExport(supabase: any, agencyId: string, docketId: string | null, filters: any) {
+ // Build query for comments
+ let query = supabase
+ .from('comments')
+ .select(`
+ id,
+ content,
+ status,
+ commenter_name,
+ commenter_email,
+ commenter_organization,
+ created_at,
+ updated_at,
+ dockets!inner (
+ id,
+ title,
+ reference_code,
+ agency_id
+ ),
+ comment_attachments (
+ id,
+ filename,
+ file_size
+ )
+ `)
+ .eq('dockets.agency_id', agencyId)
+
+ if (docketId) {
+ query = query.eq('docket_id', docketId)
+ }
+
+ if (filters.comment_statuses?.length) {
+ query = query.in('status', filters.comment_statuses)
+ }
+
+ if (filters.date_from) {
+ query = query.gte('created_at', filters.date_from)
+ }
+
+ if (filters.date_to) {
+ query = query.lte('created_at', filters.date_to)
+ }
+
+ const { data: comments, error } = await query.order('created_at', { ascending: false })
+
+ if (error) {
+ throw new Error(`Failed to fetch comments: ${error.message}`)
+ }
+
+ // Generate CSV
+ const headers = [
+ 'Comment ID',
+ 'Docket ID',
+ 'Docket Title',
+ 'Reference Code',
+ 'Commenter Name',
+ 'Commenter Email',
+ 'Organization',
+ 'Comment Text',
+ 'Status',
+ 'Attachment Count',
+ 'Attachment Files',
+ 'Submitted At',
+ 'Updated At'
+ ]
+
+ const rows = comments.map((comment: any) => [
+ comment.id,
+ comment.dockets.id,
+ comment.dockets.title,
+ comment.dockets.reference_code || '',
+ comment.commenter_name || '',
+ comment.commenter_email || '',
+ comment.commenter_organization || '',
+ `"${comment.content.replace(/"/g, '""')}"`, // Escape quotes
+ comment.status,
+ comment.comment_attachments?.length || 0,
+ comment.comment_attachments?.map((a: any) => a.filename).join('; ') || '',
+ comment.created_at,
+ comment.updated_at
+ ])
+
+ const csv = [headers, ...rows]
+ .map(row => row.join(','))
+ .join('\n')
+
+ return { csv, count: comments.length }
+}
+
+async function generateZIPExport(supabase: any, agencyId: string, docketId: string | null, filters: any) {
+ // This is a simplified version - in production, you'd use a proper ZIP library
+ // For now, return a placeholder
+ const placeholder = 'ZIP export functionality requires additional ZIP library implementation'
+ return {
+ zipBuffer: new TextEncoder().encode(placeholder),
+ count: 0
+ }
+}
+
+async function generateCombinedExport(supabase: any, agencyId: string, docketId: string | null, filters: any) {
+ // This would combine CSV + ZIP into a single archive
+ // For now, return a placeholder
+ const placeholder = 'Combined export functionality requires additional implementation'
+ return {
+ zipBuffer: new TextEncoder().encode(placeholder),
+ count: 0
+ }
+}
\ No newline at end of file
diff --git a/supabase/functions/send-comment-confirmation/index.ts b/supabase/functions/send-comment-confirmation/index.ts
new file mode 100644
index 0000000..5fbc421
--- /dev/null
+++ b/supabase/functions/send-comment-confirmation/index.ts
@@ -0,0 +1,128 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+interface CommentConfirmationRequest {
+ comment_id: string
+ tracking_id: string
+ commenter_email?: string
+ commenter_name?: string
+ docket_title: string
+ agency_name: string
+}
+
+serve(async (req) => {
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders })
+ }
+
+ try {
+ const {
+ comment_id,
+ tracking_id,
+ commenter_email,
+ commenter_name,
+ docket_title,
+ agency_name
+ }: CommentConfirmationRequest = await req.json()
+
+ // Only send email if email address was provided
+ if (!commenter_email) {
+ return new Response(
+ JSON.stringify({ success: true, message: 'No email provided, skipping confirmation' }),
+ { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ )
+ }
+
+ // Get Resend API key from environment
+ const resendApiKey = Deno.env.get('RESEND_API_KEY')
+ if (!resendApiKey) {
+ console.warn('RESEND_API_KEY not configured, skipping email send')
+ return new Response(
+ JSON.stringify({ success: true, message: 'Email service not configured' }),
+ { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ )
+ }
+
+ // Format confirmation email
+ const emailSubject = `Comment Submitted: ${docket_title}`
+ const emailBody = `
+Dear ${commenter_name || 'Commenter'},
+
+Thank you for submitting your public comment on "${docket_title}" with ${agency_name}.
+
+Your comment has been received and assigned tracking ID: ${tracking_id}
+
+What happens next:
+1. Your comment will be reviewed by agency staff
+2. Once approved, it will be published on the public docket page
+3. All comments will be considered in the agency's decision-making process
+
+You can view the docket and other public comments at:
+${Deno.env.get('SITE_URL') || 'https://opencomments.us'}/dockets
+
+Thank you for participating in the democratic process. Your voice matters!
+
+Best regards,
+The OpenComments Team
+
+---
+This is an automated confirmation email. Please do not reply to this message.
+If you have questions, contact the agency directly or visit our help center.
+ `.trim()
+
+ // Send email via Resend
+ const emailResponse = await fetch('https://api.resend.com/emails', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${resendApiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ from: 'OpenComments ',
+ to: [commenter_email],
+ subject: emailSubject,
+ text: emailBody,
+ html: emailBody.replace(/\n/g, ' ')
+ }),
+ })
+
+ if (!emailResponse.ok) {
+ const errorText = await emailResponse.text()
+ console.error('Resend API error:', errorText)
+ throw new Error('Failed to send confirmation email')
+ }
+
+ const emailResult = await emailResponse.json()
+ console.log('Confirmation email sent:', emailResult.id)
+
+ return new Response(
+ JSON.stringify({
+ success: true,
+ message: 'Confirmation email sent',
+ email_id: emailResult.id
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 200
+ }
+ )
+
+ } catch (error) {
+ console.error('Comment confirmation error:', error)
+
+ return new Response(
+ JSON.stringify({
+ error: error.message || 'Failed to send confirmation email',
+ success: false
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 500
+ }
+ )
+ }
+})
\ No newline at end of file
diff --git a/supabase/functions/send-contact-email/index.ts b/supabase/functions/send-contact-email/index.ts
new file mode 100644
index 0000000..37d7dc2
--- /dev/null
+++ b/supabase/functions/send-contact-email/index.ts
@@ -0,0 +1,113 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+interface ContactEmailRequest {
+ name: string
+ email: string
+ organization?: string
+ subject: string
+ category: string
+ message: string
+}
+
+serve(async (req) => {
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders })
+ }
+
+ try {
+ const {
+ name,
+ email,
+ organization,
+ subject,
+ category,
+ message
+ }: ContactEmailRequest = await req.json()
+
+ // Get Resend API key from environment
+ const resendApiKey = Deno.env.get('RESEND_API_KEY')
+ if (!resendApiKey) {
+ console.warn('RESEND_API_KEY not configured, skipping email send')
+ return new Response(
+ JSON.stringify({ success: true, message: 'Contact form submitted (email disabled)' }),
+ { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ )
+ }
+
+ // Format email content
+ const emailSubject = `[OpenComments] ${category.replace('_', ' ').toUpperCase()}: ${subject}`
+ const emailBody = `
+New contact form submission:
+
+Name: ${name}
+Email: ${email}
+Organization: ${organization || 'Not provided'}
+Category: ${category.replace('_', ' ')}
+Subject: ${subject}
+
+Message:
+${message}
+
+---
+Submitted via OpenComments Contact Form
+Time: ${new Date().toISOString()}
+ `.trim()
+
+ // Send email via Resend
+ const emailResponse = await fetch('https://api.resend.com/emails', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${resendApiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ from: 'OpenComments ',
+ to: ['support@opencomments.us'],
+ reply_to: email,
+ subject: emailSubject,
+ text: emailBody,
+ html: emailBody.replace(/\n/g, ' ')
+ }),
+ })
+
+ if (!emailResponse.ok) {
+ const errorText = await emailResponse.text()
+ console.error('Resend API error:', errorText)
+ throw new Error('Failed to send email notification')
+ }
+
+ const emailResult = await emailResponse.json()
+ console.log('Email sent successfully:', emailResult.id)
+
+ return new Response(
+ JSON.stringify({
+ success: true,
+ message: 'Contact form submitted and email sent',
+ email_id: emailResult.id
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 200
+ }
+ )
+
+ } catch (error) {
+ console.error('Contact form error:', error)
+
+ return new Response(
+ JSON.stringify({
+ error: error.message || 'Failed to process contact form',
+ success: false
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 500
+ }
+ )
+ }
+})
\ No newline at end of file
diff --git a/supabase/migrations/20250727170612_navy_boat.sql b/supabase/migrations/20250727170612_navy_boat.sql
new file mode 100644
index 0000000..1389b64
--- /dev/null
+++ b/supabase/migrations/20250727170612_navy_boat.sql
@@ -0,0 +1,518 @@
+/*
+ # Agency Admin Schema Migration
+
+ 1. New Tables
+ - `agencies` - Agency organizations with settings
+ - `agency_members` - User memberships in agencies with roles
+ - `agency_invitations` - Pending invitations to join agencies
+ - `dockets` - Public comment periods/windows
+ - `docket_attachments` - Supporting documents for dockets
+ - `comment_attachments` - Files attached to public comments
+ - `moderation_logs` - Audit trail for moderation actions
+ - `docket_tags` - Predefined topic tags for categorization
+
+ 2. Enums
+ - `agency_role` - Five-tier role hierarchy
+ - `docket_status` - Lifecycle states for comment windows
+ - `comment_status` - Moderation states for submissions
+ - `moderation_action` - Types of moderation actions
+
+ 3. Security
+ - Enable RLS on all new tables
+ - Role-based access policies
+ - Audit logging for sensitive operations
+
+ 4. Updates to Existing Tables
+ - Update existing `dockets` table structure
+ - Enhance `comments` table with new fields
+ - Add agency relationship to existing profiles
+*/
+
+-- Create enums for type safety
+CREATE TYPE agency_role AS ENUM ('owner', 'admin', 'manager', 'reviewer', 'viewer');
+CREATE TYPE docket_status AS ENUM ('draft', 'open', 'closed', 'archived');
+CREATE TYPE comment_status AS ENUM ('pending', 'approved', 'rejected', 'flagged');
+CREATE TYPE moderation_action AS ENUM ('approve', 'reject', 'flag', 'unflag', 'edit', 'delete');
+
+-- Agencies table
+CREATE TABLE IF NOT EXISTS agencies (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ name text NOT NULL,
+ jurisdiction text,
+ description text,
+ logo_url text,
+ settings jsonb DEFAULT '{}',
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Agency memberships (replaces single agency_name in profiles)
+CREATE TABLE IF NOT EXISTS agency_members (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id uuid NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ role agency_role NOT NULL DEFAULT 'reviewer',
+ invited_by uuid REFERENCES profiles(id),
+ joined_at timestamptz DEFAULT now(),
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now(),
+ UNIQUE(agency_id, user_id)
+);
+
+-- Agency invitations for invite-only access
+CREATE TABLE IF NOT EXISTS agency_invitations (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id uuid NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+ email text NOT NULL,
+ role agency_role NOT NULL DEFAULT 'reviewer',
+ invited_by uuid NOT NULL REFERENCES profiles(id),
+ token text UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
+ expires_at timestamptz NOT NULL DEFAULT (now() + interval '7 days'),
+ accepted_at timestamptz,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Predefined tags for docket categorization
+CREATE TABLE IF NOT EXISTS docket_tags (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ name text UNIQUE NOT NULL,
+ description text,
+ color text DEFAULT '#3B82F6',
+ created_at timestamptz DEFAULT now()
+);
+
+-- Update existing dockets table structure
+DO $$
+BEGIN
+ -- Add new columns if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'summary') THEN
+ ALTER TABLE dockets ADD COLUMN summary text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'slug') THEN
+ ALTER TABLE dockets ADD COLUMN slug text UNIQUE;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'reference_code') THEN
+ ALTER TABLE dockets ADD COLUMN reference_code text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'tags') THEN
+ ALTER TABLE dockets ADD COLUMN tags text[] DEFAULT '{}';
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'open_at') THEN
+ ALTER TABLE dockets ADD COLUMN open_at timestamptz;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'close_at') THEN
+ ALTER TABLE dockets ADD COLUMN close_at timestamptz;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'settings') THEN
+ ALTER TABLE dockets ADD COLUMN settings jsonb DEFAULT '{}';
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'auto_publish') THEN
+ ALTER TABLE dockets ADD COLUMN auto_publish boolean DEFAULT false;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'require_captcha') THEN
+ ALTER TABLE dockets ADD COLUMN require_captcha boolean DEFAULT true;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'max_file_size_mb') THEN
+ ALTER TABLE dockets ADD COLUMN max_file_size_mb integer DEFAULT 10;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'allowed_file_types') THEN
+ ALTER TABLE dockets ADD COLUMN allowed_file_types text[] DEFAULT ARRAY['pdf', 'docx', 'jpg', 'png'];
+ END IF;
+END $$;
+
+-- Supporting documents for dockets
+CREATE TABLE IF NOT EXISTS docket_attachments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ docket_id uuid NOT NULL REFERENCES dockets(id) ON DELETE CASCADE,
+ filename text NOT NULL,
+ file_url text NOT NULL,
+ file_size bigint NOT NULL,
+ mime_type text NOT NULL,
+ uploaded_by uuid NOT NULL REFERENCES profiles(id),
+ created_at timestamptz DEFAULT now()
+);
+
+-- Update existing comments table
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'commenter_name') THEN
+ ALTER TABLE comments ADD COLUMN commenter_name text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'commenter_email') THEN
+ ALTER TABLE comments ADD COLUMN commenter_email text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'commenter_organization') THEN
+ ALTER TABLE comments ADD COLUMN commenter_organization text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'ip_address') THEN
+ ALTER TABLE comments ADD COLUMN ip_address inet;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'user_agent') THEN
+ ALTER TABLE comments ADD COLUMN user_agent text;
+ END IF;
+END $$;
+
+-- File attachments for public comments
+CREATE TABLE IF NOT EXISTS comment_attachments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ filename text NOT NULL,
+ file_url text NOT NULL,
+ file_size bigint NOT NULL,
+ mime_type text NOT NULL,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Moderation audit log
+CREATE TABLE IF NOT EXISTS moderation_logs (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ action moderation_action NOT NULL,
+ actor_id uuid NOT NULL REFERENCES profiles(id),
+ previous_status comment_status,
+ new_status comment_status,
+ reason text,
+ notes text,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Enable RLS on all new tables
+ALTER TABLE agencies ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_members ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_invitations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE docket_tags ENABLE ROW LEVEL SECURITY;
+ALTER TABLE docket_attachments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE comment_attachments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE moderation_logs ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies for agencies
+CREATE POLICY "Users can read agencies they belong to"
+ ON agencies
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = agencies.id
+ AND agency_members.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can update agency"
+ ON agencies
+ FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = agencies.id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.role IN ('owner', 'admin')
+ )
+ );
+
+-- RLS Policies for agency members
+CREATE POLICY "Users can read agency members for their agencies"
+ ON agency_members
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am2
+ WHERE am2.agency_id = agency_members.agency_id
+ AND am2.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can manage members"
+ ON agency_members
+ FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am2
+ WHERE am2.agency_id = agency_members.agency_id
+ AND am2.user_id = auth.uid()
+ AND am2.role IN ('owner', 'admin')
+ )
+ );
+
+-- RLS Policies for dockets (update existing)
+DROP POLICY IF EXISTS "Agencies can manage own dockets" ON dockets;
+DROP POLICY IF EXISTS "Public can read open dockets" ON dockets;
+
+CREATE POLICY "Agency members can read dockets"
+ ON dockets
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = dockets.agency_id
+ AND agency_members.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Public can read open dockets"
+ ON dockets
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 'open');
+
+CREATE POLICY "Agency managers+ can create dockets"
+ ON dockets
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = dockets.agency_id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.role IN ('owner', 'admin', 'manager')
+ )
+ );
+
+CREATE POLICY "Agency managers+ can update dockets"
+ ON dockets
+ FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = dockets.agency_id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.role IN ('owner', 'admin', 'manager')
+ )
+ );
+
+-- RLS Policies for comments (update existing)
+DROP POLICY IF EXISTS "Users can read own comments" ON comments;
+DROP POLICY IF EXISTS "Users can create comments on open dockets" ON comments;
+DROP POLICY IF EXISTS "Agencies can read comments on own dockets" ON comments;
+DROP POLICY IF EXISTS "Agencies can update comment status on own dockets" ON comments;
+
+CREATE POLICY "Users can read own comments"
+ ON comments
+ FOR SELECT
+ TO authenticated
+ USING (auth.uid() = user_id);
+
+CREATE POLICY "Agency members can read comments on agency dockets"
+ ON comments
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = comments.docket_id
+ AND am.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Public can read approved comments"
+ ON comments
+ FOR SELECT
+ TO anon, authenticated
+ USING (
+ status = 'published' AND
+ EXISTS (
+ SELECT 1 FROM dockets
+ WHERE dockets.id = comments.docket_id
+ AND dockets.status = 'open'
+ )
+ );
+
+CREATE POLICY "Users can create comments on open dockets"
+ ON comments
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM dockets
+ WHERE dockets.id = comments.docket_id
+ AND dockets.status = 'open'
+ AND (dockets.close_at IS NULL OR dockets.close_at > now())
+ )
+ AND auth.uid() = user_id
+ );
+
+CREATE POLICY "Agency reviewers+ can update comment status"
+ ON comments
+ FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = comments.docket_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin', 'manager', 'reviewer')
+ )
+ );
+
+-- RLS Policies for attachments
+CREATE POLICY "Users can read attachments for accessible comments"
+ ON comment_attachments
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments
+ WHERE comments.id = comment_attachments.comment_id
+ AND (
+ comments.user_id = auth.uid() OR
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = comments.docket_id
+ AND am.user_id = auth.uid()
+ )
+ )
+ )
+ );
+
+CREATE POLICY "Agency members can read docket attachments"
+ ON docket_attachments
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = docket_attachments.docket_id
+ AND am.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Public can read attachments for open dockets"
+ ON docket_attachments
+ FOR SELECT
+ TO anon, authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets
+ WHERE dockets.id = docket_attachments.docket_id
+ AND dockets.status = 'open'
+ )
+ );
+
+-- RLS Policies for moderation logs
+CREATE POLICY "Agency members can read moderation logs for their dockets"
+ ON moderation_logs
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = moderation_logs.comment_id
+ AND am.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency reviewers+ can create moderation logs"
+ ON moderation_logs
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = moderation_logs.comment_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin', 'manager', 'reviewer')
+ )
+ AND auth.uid() = actor_id
+ );
+
+-- Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_agency_members_user_id ON agency_members(user_id);
+CREATE INDEX IF NOT EXISTS idx_agency_members_agency_id ON agency_members(agency_id);
+CREATE INDEX IF NOT EXISTS idx_agency_members_role ON agency_members(role);
+CREATE INDEX IF NOT EXISTS idx_dockets_agency_id ON dockets(agency_id);
+CREATE INDEX IF NOT EXISTS idx_dockets_status ON dockets(status);
+CREATE INDEX IF NOT EXISTS idx_dockets_slug ON dockets(slug);
+CREATE INDEX IF NOT EXISTS idx_comments_docket_id ON comments(docket_id);
+CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status);
+CREATE INDEX IF NOT EXISTS idx_comment_attachments_comment_id ON comment_attachments(comment_id);
+CREATE INDEX IF NOT EXISTS idx_docket_attachments_docket_id ON docket_attachments(docket_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_comment_id ON moderation_logs(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_actor_id ON moderation_logs(actor_id);
+
+-- Create updated_at triggers
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+CREATE TRIGGER update_agencies_updated_at BEFORE UPDATE ON agencies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER update_agency_members_updated_at BEFORE UPDATE ON agency_members FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Helper function to get user's role in an agency
+CREATE OR REPLACE FUNCTION get_user_agency_role(agency_uuid uuid)
+RETURNS agency_role AS $$
+BEGIN
+ RETURN (
+ SELECT role FROM agency_members
+ WHERE agency_id = agency_uuid
+ AND user_id = auth.uid()
+ );
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Helper function to check if user has permission in agency
+CREATE OR REPLACE FUNCTION user_has_agency_permission(agency_uuid uuid, required_role agency_role)
+RETURNS boolean AS $$
+DECLARE
+ user_role agency_role;
+ role_hierarchy integer;
+ required_hierarchy integer;
+BEGIN
+ -- Get user's role in the agency
+ SELECT role INTO user_role FROM agency_members
+ WHERE agency_id = agency_uuid AND user_id = auth.uid();
+
+ IF user_role IS NULL THEN
+ RETURN false;
+ END IF;
+
+ -- Define role hierarchy (higher number = more permissions)
+ role_hierarchy := CASE user_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ required_hierarchy := CASE required_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ RETURN role_hierarchy >= required_hierarchy;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
\ No newline at end of file
diff --git a/supabase/migrations/20250727170709_tiny_villa.sql b/supabase/migrations/20250727170709_tiny_villa.sql
new file mode 100644
index 0000000..e189171
--- /dev/null
+++ b/supabase/migrations/20250727170709_tiny_villa.sql
@@ -0,0 +1,76 @@
+/*
+ # Seed Reference Data
+
+ 1. Docket Tags
+ - Common topic categories for government dockets
+
+ 2. File Type Whitelist
+ - Allowed MIME types for uploads
+
+ 3. Default Agency Settings
+ - Template configuration options
+*/
+
+-- Insert predefined docket tags
+INSERT INTO docket_tags (name, description, color) VALUES
+ ('Budget', 'Budget proposals and financial planning', '#10B981'),
+ ('Transportation', 'Roads, transit, and transportation infrastructure', '#3B82F6'),
+ ('Housing', 'Housing policy and development', '#8B5CF6'),
+ ('Environment', 'Environmental protection and sustainability', '#059669'),
+ ('Public Safety', 'Police, fire, emergency services', '#DC2626'),
+ ('Parks & Recreation', 'Parks, recreation facilities, and programs', '#65A30D'),
+ ('Zoning', 'Land use and zoning regulations', '#D97706'),
+ ('Economic Development', 'Business development and economic policy', '#7C3AED'),
+ ('Health', 'Public health and healthcare services', '#DB2777'),
+ ('Education', 'Schools and educational programs', '#2563EB'),
+ ('Utilities', 'Water, sewer, electricity, and utilities', '#0891B2'),
+ ('Technology', 'IT infrastructure and digital services', '#7C2D12')
+ON CONFLICT (name) DO NOTHING;
+
+-- Create a reference table for allowed file types
+CREATE TABLE IF NOT EXISTS allowed_file_types (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ extension text UNIQUE NOT NULL,
+ mime_type text NOT NULL,
+ description text NOT NULL,
+ max_size_mb integer DEFAULT 10,
+ is_active boolean DEFAULT true,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Insert allowed file types
+INSERT INTO allowed_file_types (extension, mime_type, description, max_size_mb) VALUES
+ ('pdf', 'application/pdf', 'PDF Documents', 25),
+ ('docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Word Documents (DOCX)', 10),
+ ('doc', 'application/msword', 'Word Documents (DOC)', 10),
+ ('txt', 'text/plain', 'Plain Text Files', 5),
+ ('rtf', 'application/rtf', 'Rich Text Format', 5),
+ ('jpg', 'image/jpeg', 'JPEG Images', 15),
+ ('jpeg', 'image/jpeg', 'JPEG Images', 15),
+ ('png', 'image/png', 'PNG Images', 15),
+ ('gif', 'image/gif', 'GIF Images', 10),
+ ('webp', 'image/webp', 'WebP Images', 10),
+ ('csv', 'text/csv', 'CSV Spreadsheets', 5),
+ ('xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Excel Spreadsheets', 10)
+ON CONFLICT (extension) DO NOTHING;
+
+-- Enable RLS on reference tables
+ALTER TABLE allowed_file_types ENABLE ROW LEVEL SECURITY;
+
+-- Allow public read access to reference data
+CREATE POLICY "Anyone can read docket tags"
+ ON docket_tags
+ FOR SELECT
+ TO anon, authenticated
+ USING (true);
+
+CREATE POLICY "Anyone can read allowed file types"
+ ON allowed_file_types
+ FOR SELECT
+ TO anon, authenticated
+ USING (is_active = true);
+
+-- Create indexes for reference tables
+CREATE INDEX IF NOT EXISTS idx_docket_tags_name ON docket_tags(name);
+CREATE INDEX IF NOT EXISTS idx_allowed_file_types_extension ON allowed_file_types(extension);
+CREATE INDEX IF NOT EXISTS idx_allowed_file_types_active ON allowed_file_types(is_active);
\ No newline at end of file
diff --git a/supabase/migrations/20250727170839_bronze_term.sql b/supabase/migrations/20250727170839_bronze_term.sql
new file mode 100644
index 0000000..ac21eae
--- /dev/null
+++ b/supabase/migrations/20250727170839_bronze_term.sql
@@ -0,0 +1,390 @@
+/*
+ # Align Agency Schema with Data Model Checklist
+
+ This migration ensures our schema matches the specified data model checklist:
+
+ 1. Tables & Relationships
+ - `agencies` - One row per organization
+ - `agency_users` - Maps auth.users to agencies with roles (renamed from agency_members)
+ - `dockets` - Public comment windows
+ - `tags` - Reusable topic labels
+ - `docket_tags` - Many-to-many join table for dockets โ tags
+ - `comments` - Public submissions
+ - `attachments` - Files uploaded with comments
+ - `moderation_logs` - Audit trail for moderation actions
+ - `agency_settings` - Per-agency configuration overrides
+
+ 2. Enums
+ - `agency_role` - Five-tier role hierarchy
+ - `docket_status` - Comment window states
+ - `comment_status` - Submission states
+ - `moderation_action` - Moderation action types
+
+ 3. Security
+ - RLS enabled on all tables
+ - Agency-scoped access control
+ - Role-based permissions
+*/
+
+-- Create agency_role enum if it doesn't exist
+DO $$ BEGIN
+ CREATE TYPE agency_role AS ENUM ('owner', 'admin', 'manager', 'reviewer', 'viewer');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Create docket_status enum if it doesn't exist
+DO $$ BEGIN
+ CREATE TYPE docket_status AS ENUM ('draft', 'open', 'closed', 'archived');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Create comment_status enum if it doesn't exist
+DO $$ BEGIN
+ CREATE TYPE comment_status AS ENUM ('pending', 'approved', 'rejected', 'flagged');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Create moderation_action enum if it doesn't exist
+DO $$ BEGIN
+ CREATE TYPE moderation_action AS ENUM ('approve', 'reject', 'flag', 'unflag', 'edit', 'delete');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Create agencies table
+CREATE TABLE IF NOT EXISTS agencies (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ slug text UNIQUE NOT NULL,
+ name text NOT NULL,
+ jurisdiction text,
+ logo_url text,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Create agency_users table (many-to-many mapping)
+CREATE TABLE IF NOT EXISTS agency_users (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id uuid NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ role agency_role NOT NULL DEFAULT 'reviewer',
+ joined_at timestamptz DEFAULT now(),
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now(),
+ UNIQUE(agency_id, user_id)
+);
+
+-- Create tags table
+CREATE TABLE IF NOT EXISTS tags (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ name text UNIQUE NOT NULL,
+ description text,
+ color text DEFAULT '#3B82F6',
+ created_at timestamptz DEFAULT now()
+);
+
+-- Update dockets table to match checklist
+DO $$
+BEGIN
+ -- Add missing columns if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'slug') THEN
+ ALTER TABLE dockets ADD COLUMN slug text UNIQUE;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'summary') THEN
+ ALTER TABLE dockets ADD COLUMN summary text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'open_at') THEN
+ ALTER TABLE dockets ADD COLUMN open_at timestamptz;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'close_at') THEN
+ ALTER TABLE dockets ADD COLUMN close_at timestamptz;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'created_by') THEN
+ ALTER TABLE dockets ADD COLUMN created_by uuid REFERENCES auth.users(id);
+ END IF;
+
+ -- Update status column to use enum if it's still text
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'status' AND data_type = 'text') THEN
+ ALTER TABLE dockets ALTER COLUMN status TYPE docket_status USING status::docket_status;
+ END IF;
+END $$;
+
+-- Create docket_tags join table (many-to-many)
+CREATE TABLE IF NOT EXISTS docket_tags (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ docket_id uuid NOT NULL REFERENCES dockets(id) ON DELETE CASCADE,
+ tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ created_at timestamptz DEFAULT now(),
+ UNIQUE(docket_id, tag_id)
+);
+
+-- Update comments table to match checklist
+DO $$
+BEGIN
+ -- Add missing columns if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'submitter_name') THEN
+ ALTER TABLE comments ADD COLUMN submitter_name text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'submitter_email') THEN
+ ALTER TABLE comments ADD COLUMN submitter_email text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'body') THEN
+ ALTER TABLE comments ADD COLUMN body text;
+ END IF;
+
+ -- Update status column to use enum if it's still text
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'status' AND data_type = 'text') THEN
+ ALTER TABLE comments ALTER COLUMN status TYPE comment_status USING status::comment_status;
+ END IF;
+END $$;
+
+-- Create attachments table
+CREATE TABLE IF NOT EXISTS attachments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ file_url text NOT NULL,
+ mime_type text NOT NULL,
+ file_size bigint NOT NULL,
+ text_extracted text, -- Future feature for searchable content
+ created_at timestamptz DEFAULT now()
+);
+
+-- Create moderation_logs table
+CREATE TABLE IF NOT EXISTS moderation_logs (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ action moderation_action NOT NULL,
+ actor_id uuid NOT NULL REFERENCES auth.users(id),
+ timestamp timestamptz DEFAULT now(),
+ reason text,
+ notes text,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Create agency_settings table (1-to-1 with agencies)
+CREATE TABLE IF NOT EXISTS agency_settings (
+ agency_id uuid PRIMARY KEY REFERENCES agencies(id) ON DELETE CASCADE,
+ max_file_size_mb integer DEFAULT 10,
+ allowed_mime_types text[] DEFAULT ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/jpeg', 'image/png'],
+ captcha_enabled boolean DEFAULT true,
+ auto_publish boolean DEFAULT false,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_agency_users_agency_id ON agency_users(agency_id);
+CREATE INDEX IF NOT EXISTS idx_agency_users_user_id ON agency_users(user_id);
+CREATE INDEX IF NOT EXISTS idx_agency_users_role ON agency_users(role);
+CREATE INDEX IF NOT EXISTS idx_dockets_agency_id ON dockets(agency_id);
+CREATE INDEX IF NOT EXISTS idx_dockets_status ON dockets(status);
+CREATE INDEX IF NOT EXISTS idx_dockets_slug ON dockets(slug);
+CREATE INDEX IF NOT EXISTS idx_docket_tags_docket_id ON docket_tags(docket_id);
+CREATE INDEX IF NOT EXISTS idx_docket_tags_tag_id ON docket_tags(tag_id);
+CREATE INDEX IF NOT EXISTS idx_comments_docket_id ON comments(docket_id);
+CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status);
+CREATE INDEX IF NOT EXISTS idx_attachments_comment_id ON attachments(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_comment_id ON moderation_logs(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_actor_id ON moderation_logs(actor_id);
+
+-- Enable RLS on all tables
+ALTER TABLE agencies ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_users ENABLE ROW LEVEL SECURITY;
+ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
+ALTER TABLE docket_tags ENABLE ROW LEVEL SECURITY;
+ALTER TABLE attachments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE moderation_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_settings ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies for agencies
+CREATE POLICY "Users can read agencies they belong to" ON agencies
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agencies.id
+ AND agency_users.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can update agency" ON agencies
+ FOR UPDATE TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agencies.id
+ AND agency_users.user_id = auth.uid()
+ AND agency_users.role IN ('owner', 'admin')
+ )
+ );
+
+-- RLS Policies for agency_users
+CREATE POLICY "Users can read agency members for their agencies" ON agency_users
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users au2
+ WHERE au2.agency_id = agency_users.agency_id
+ AND au2.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can manage members" ON agency_users
+ FOR ALL TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users au2
+ WHERE au2.agency_id = agency_users.agency_id
+ AND au2.user_id = auth.uid()
+ AND au2.role IN ('owner', 'admin')
+ )
+ );
+
+-- RLS Policies for tags (readable by all authenticated users)
+CREATE POLICY "Anyone can read tags" ON tags
+ FOR SELECT TO authenticated
+ USING (true);
+
+-- RLS Policies for docket_tags
+CREATE POLICY "Users can read docket tags for accessible dockets" ON docket_tags
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_users au ON au.agency_id = d.agency_id
+ WHERE d.id = docket_tags.docket_id
+ AND au.user_id = auth.uid()
+ )
+ );
+
+-- RLS Policies for attachments
+CREATE POLICY "Users can read attachments for accessible comments" ON attachments
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_users au ON au.agency_id = d.agency_id
+ WHERE c.id = attachments.comment_id
+ AND au.user_id = auth.uid()
+ )
+ );
+
+-- RLS Policies for moderation_logs
+CREATE POLICY "Agency members can read moderation logs for their dockets" ON moderation_logs
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_users au ON au.agency_id = d.agency_id
+ WHERE c.id = moderation_logs.comment_id
+ AND au.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency reviewers+ can create moderation logs" ON moderation_logs
+ FOR INSERT TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_users au ON au.agency_id = d.agency_id
+ WHERE c.id = moderation_logs.comment_id
+ AND au.user_id = auth.uid()
+ AND au.role IN ('owner', 'admin', 'manager', 'reviewer')
+ )
+ AND auth.uid() = actor_id
+ );
+
+-- RLS Policies for agency_settings
+CREATE POLICY "Agency members can read settings" ON agency_settings
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agency_settings.agency_id
+ AND agency_users.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency admins+ can update settings" ON agency_settings
+ FOR ALL TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agency_settings.agency_id
+ AND agency_users.user_id = auth.uid()
+ AND agency_users.role IN ('owner', 'admin')
+ )
+ );
+
+-- Create updated_at triggers
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+CREATE TRIGGER update_agencies_updated_at BEFORE UPDATE ON agencies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER update_agency_users_updated_at BEFORE UPDATE ON agency_users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER update_agency_settings_updated_at BEFORE UPDATE ON agency_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Helper function to get user's role in an agency
+CREATE OR REPLACE FUNCTION get_user_agency_role(user_uuid uuid, agency_uuid uuid)
+RETURNS agency_role AS $$
+BEGIN
+ RETURN (
+ SELECT role FROM agency_users
+ WHERE user_id = user_uuid AND agency_id = agency_uuid
+ );
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Helper function to check if user has permission in agency
+CREATE OR REPLACE FUNCTION user_has_agency_permission(user_uuid uuid, agency_uuid uuid, required_role agency_role)
+RETURNS boolean AS $$
+DECLARE
+ user_role agency_role;
+ role_hierarchy integer;
+ required_hierarchy integer;
+BEGIN
+ -- Get user's role in the agency
+ SELECT role INTO user_role FROM agency_users
+ WHERE user_id = user_uuid AND agency_id = agency_uuid;
+
+ IF user_role IS NULL THEN
+ RETURN false;
+ END IF;
+
+ -- Convert roles to hierarchy levels (higher number = more permissions)
+ role_hierarchy := CASE user_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ required_hierarchy := CASE required_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ RETURN role_hierarchy >= required_hierarchy;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
\ No newline at end of file
diff --git a/supabase/migrations/20250727170924_quiet_stream.sql b/supabase/migrations/20250727170924_quiet_stream.sql
new file mode 100644
index 0000000..04672f2
--- /dev/null
+++ b/supabase/migrations/20250727170924_quiet_stream.sql
@@ -0,0 +1,70 @@
+/*
+ # Seed Reference Data for Agency System
+
+ This migration adds initial reference data:
+ 1. Common docket tags for categorization
+ 2. Default agency settings
+ 3. Example data for development
+*/
+
+-- Insert common docket tags
+INSERT INTO tags (name, description, color) VALUES
+ ('Budget', 'Budget proposals and financial planning', '#10B981'),
+ ('Transportation', 'Roads, transit, and transportation infrastructure', '#3B82F6'),
+ ('Housing', 'Housing development and zoning', '#F59E0B'),
+ ('Environment', 'Environmental impact and sustainability', '#059669'),
+ ('Public Safety', 'Police, fire, and emergency services', '#DC2626'),
+ ('Parks & Recreation', 'Parks, recreation facilities, and programs', '#65A30D'),
+ ('Zoning', 'Land use and zoning regulations', '#7C3AED'),
+ ('Economic Development', 'Business development and economic policy', '#EA580C'),
+ ('Health', 'Public health and healthcare services', '#DB2777'),
+ ('Education', 'Schools and educational programs', '#2563EB'),
+ ('Utilities', 'Water, sewer, and utility services', '#0891B2'),
+ ('Planning', 'Urban planning and development', '#7C2D12')
+ON CONFLICT (name) DO NOTHING;
+
+-- Function to create default agency settings when agency is created
+CREATE OR REPLACE FUNCTION create_default_agency_settings()
+RETURNS TRIGGER AS $$
+BEGIN
+ INSERT INTO agency_settings (agency_id)
+ VALUES (NEW.id);
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Trigger to automatically create settings for new agencies
+DROP TRIGGER IF EXISTS create_agency_settings_trigger ON agencies;
+CREATE TRIGGER create_agency_settings_trigger
+ AFTER INSERT ON agencies
+ FOR EACH ROW
+ EXECUTE FUNCTION create_default_agency_settings();
+
+-- Constraint to ensure at least one owner per agency
+CREATE OR REPLACE FUNCTION ensure_agency_has_owner()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- If this is a delete or role change from owner, check if there's still an owner
+ IF (TG_OP = 'DELETE' AND OLD.role = 'owner') OR
+ (TG_OP = 'UPDATE' AND OLD.role = 'owner' AND NEW.role != 'owner') THEN
+
+ -- Count remaining owners for this agency
+ IF (SELECT COUNT(*) FROM agency_users
+ WHERE agency_id = COALESCE(OLD.agency_id, NEW.agency_id)
+ AND role = 'owner'
+ AND id != COALESCE(OLD.id, NEW.id)) = 0 THEN
+
+ RAISE EXCEPTION 'Cannot remove last owner from agency. Agency must have at least one owner.';
+ END IF;
+ END IF;
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+-- Trigger to enforce at least one owner per agency
+DROP TRIGGER IF EXISTS ensure_owner_trigger ON agency_users;
+CREATE TRIGGER ensure_owner_trigger
+ BEFORE UPDATE OR DELETE ON agency_users
+ FOR EACH ROW
+ EXECUTE FUNCTION ensure_agency_has_owner();
\ No newline at end of file
diff --git a/supabase/migrations/20250727171130_pale_ember.sql b/supabase/migrations/20250727171130_pale_ember.sql
new file mode 100644
index 0000000..7283996
--- /dev/null
+++ b/supabase/migrations/20250727171130_pale_ember.sql
@@ -0,0 +1,467 @@
+/*
+ # Align Agency Schema with Data Model Checklist
+
+ 1. Core Tables
+ - agencies: Organization records with slug, name, jurisdiction
+ - agency_users: Many-to-many user-agency mapping with roles
+ - tags: Reusable topic labels
+ - docket_tags: Many-to-many dockets โ tags
+ - dockets: Comment windows with proper scheduling
+ - comments: Public submissions with submitter details
+ - attachments: File uploads linked to comments
+ - moderation_logs: Complete audit trail
+ - agency_settings: Per-agency configuration
+
+ 2. Enums
+ - agency_role: owner, admin, manager, reviewer, viewer
+ - docket_status: draft, open, closed, archived
+ - comment_status: pending, approved, rejected, flagged
+ - moderation_action: approve, reject, flag, unflag, edit, delete
+
+ 3. Security
+ - RLS enabled on all tables
+ - Agency-scoped access policies
+ - Role hierarchy enforcement
+*/
+
+-- Create enums first
+DO $$ BEGIN
+ CREATE TYPE agency_role AS ENUM ('owner', 'admin', 'manager', 'reviewer', 'viewer');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+DO $$ BEGIN
+ CREATE TYPE docket_status AS ENUM ('draft', 'open', 'closed', 'archived');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+DO $$ BEGIN
+ CREATE TYPE comment_status AS ENUM ('pending', 'approved', 'rejected', 'flagged');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+DO $$ BEGIN
+ CREATE TYPE moderation_action AS ENUM ('approve', 'reject', 'flag', 'unflag', 'edit', 'delete');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Create agencies table
+CREATE TABLE IF NOT EXISTS agencies (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ slug text UNIQUE NOT NULL,
+ name text NOT NULL,
+ jurisdiction text,
+ logo_url text,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Create agency_users table (many-to-many mapping)
+CREATE TABLE IF NOT EXISTS agency_users (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id uuid NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ role agency_role NOT NULL DEFAULT 'reviewer',
+ joined_at timestamptz DEFAULT now(),
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now(),
+ UNIQUE(agency_id, user_id)
+);
+
+-- Create tags table
+CREATE TABLE IF NOT EXISTS tags (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ name text UNIQUE NOT NULL,
+ description text,
+ color text DEFAULT '#3B82F6',
+ created_at timestamptz DEFAULT now()
+);
+
+-- Update dockets table with new fields
+DO $$
+BEGIN
+ -- Add missing columns to dockets if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'summary') THEN
+ ALTER TABLE dockets ADD COLUMN summary text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'slug') THEN
+ ALTER TABLE dockets ADD COLUMN slug text UNIQUE;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'open_at') THEN
+ ALTER TABLE dockets ADD COLUMN open_at timestamptz;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'close_at') THEN
+ ALTER TABLE dockets ADD COLUMN close_at timestamptz;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'created_by') THEN
+ ALTER TABLE dockets ADD COLUMN created_by uuid REFERENCES auth.users(id);
+ END IF;
+
+ -- Update status column to use enum if it exists
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'status') THEN
+ -- Convert existing status column to use enum
+ ALTER TABLE dockets ALTER COLUMN status TYPE docket_status USING status::docket_status;
+ ELSE
+ ALTER TABLE dockets ADD COLUMN status docket_status DEFAULT 'draft';
+ END IF;
+END $$;
+
+-- Create docket_tags join table
+CREATE TABLE IF NOT EXISTS docket_tags (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ docket_id uuid NOT NULL REFERENCES dockets(id) ON DELETE CASCADE,
+ tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ created_at timestamptz DEFAULT now(),
+ UNIQUE(docket_id, tag_id)
+);
+
+-- Update comments table
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'submitter_name') THEN
+ ALTER TABLE comments ADD COLUMN submitter_name text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'submitter_email') THEN
+ ALTER TABLE comments ADD COLUMN submitter_email text;
+ END IF;
+
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'body') THEN
+ ALTER TABLE comments ADD COLUMN body text;
+ END IF;
+
+ -- Update status column to use enum
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'status') THEN
+ ALTER TABLE comments ALTER COLUMN status TYPE comment_status USING status::comment_status;
+ ELSE
+ ALTER TABLE comments ADD COLUMN status comment_status DEFAULT 'pending';
+ END IF;
+END $$;
+
+-- Create attachments table
+CREATE TABLE IF NOT EXISTS attachments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ file_url text NOT NULL,
+ mime_type text NOT NULL,
+ file_size bigint NOT NULL,
+ text_extracted text,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Create moderation_logs table
+CREATE TABLE IF NOT EXISTS moderation_logs (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ action moderation_action NOT NULL,
+ actor_id uuid NOT NULL REFERENCES auth.users(id),
+ timestamp timestamptz DEFAULT now(),
+ reason text,
+ notes text,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Create agency_settings table
+CREATE TABLE IF NOT EXISTS agency_settings (
+ agency_id uuid PRIMARY KEY REFERENCES agencies(id) ON DELETE CASCADE,
+ max_file_size_mb integer DEFAULT 10,
+ allowed_mime_types text[] DEFAULT ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/jpeg', 'image/png'],
+ captcha_enabled boolean DEFAULT true,
+ auto_publish boolean DEFAULT false,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_agency_users_agency_id ON agency_users(agency_id);
+CREATE INDEX IF NOT EXISTS idx_agency_users_user_id ON agency_users(user_id);
+CREATE INDEX IF NOT EXISTS idx_agency_users_role ON agency_users(role);
+CREATE INDEX IF NOT EXISTS idx_dockets_agency_id ON dockets(agency_id);
+CREATE INDEX IF NOT EXISTS idx_dockets_status ON dockets(status);
+CREATE INDEX IF NOT EXISTS idx_dockets_slug ON dockets(slug);
+CREATE INDEX IF NOT EXISTS idx_docket_tags_docket_id ON docket_tags(docket_id);
+CREATE INDEX IF NOT EXISTS idx_docket_tags_tag_id ON docket_tags(tag_id);
+CREATE INDEX IF NOT EXISTS idx_comments_docket_id ON comments(docket_id);
+CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status);
+CREATE INDEX IF NOT EXISTS idx_attachments_comment_id ON attachments(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_comment_id ON moderation_logs(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_actor_id ON moderation_logs(actor_id);
+CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
+
+-- Enable RLS on all tables
+ALTER TABLE agencies ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_users ENABLE ROW LEVEL SECURITY;
+ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
+ALTER TABLE docket_tags ENABLE ROW LEVEL SECURITY;
+ALTER TABLE attachments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE moderation_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_settings ENABLE ROW LEVEL SECURITY;
+
+-- Create RLS policies for agencies
+CREATE POLICY "Users can read agencies they belong to"
+ ON agencies FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agencies.id
+ AND agency_users.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can update agency"
+ ON agencies FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agencies.id
+ AND agency_users.user_id = auth.uid()
+ AND agency_users.role IN ('owner', 'admin')
+ )
+ );
+
+-- Create RLS policies for agency_users
+CREATE POLICY "Users can read agency members for their agencies"
+ ON agency_users FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users au2
+ WHERE au2.agency_id = agency_users.agency_id
+ AND au2.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can manage members"
+ ON agency_users FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users au2
+ WHERE au2.agency_id = agency_users.agency_id
+ AND au2.user_id = auth.uid()
+ AND au2.role IN ('owner', 'admin')
+ )
+ );
+
+-- Create RLS policies for tags (public read)
+CREATE POLICY "Anyone can read tags"
+ ON tags FOR SELECT
+ TO authenticated, anon
+ USING (true);
+
+-- Create RLS policies for docket_tags
+CREATE POLICY "Users can read docket tags for accessible dockets"
+ ON docket_tags FOR SELECT
+ TO authenticated, anon
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets
+ WHERE dockets.id = docket_tags.docket_id
+ AND (
+ dockets.status = 'open'
+ OR EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = dockets.agency_id
+ AND agency_users.user_id = auth.uid()
+ )
+ )
+ )
+ );
+
+-- Create RLS policies for attachments
+CREATE POLICY "Users can read attachments for accessible comments"
+ ON attachments FOR SELECT
+ TO authenticated, anon
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments
+ JOIN dockets ON dockets.id = comments.docket_id
+ WHERE comments.id = attachments.comment_id
+ AND (
+ (dockets.status = 'open' AND comments.status = 'approved')
+ OR EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = dockets.agency_id
+ AND agency_users.user_id = auth.uid()
+ )
+ )
+ )
+ );
+
+-- Create RLS policies for moderation_logs
+CREATE POLICY "Agency members can read moderation logs for their dockets"
+ ON moderation_logs FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments
+ JOIN dockets ON dockets.id = comments.docket_id
+ JOIN agency_users ON agency_users.agency_id = dockets.agency_id
+ WHERE comments.id = moderation_logs.comment_id
+ AND agency_users.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency reviewers+ can create moderation logs"
+ ON moderation_logs FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments
+ JOIN dockets ON dockets.id = comments.docket_id
+ JOIN agency_users ON agency_users.agency_id = dockets.agency_id
+ WHERE comments.id = moderation_logs.comment_id
+ AND agency_users.user_id = auth.uid()
+ AND agency_users.role IN ('owner', 'admin', 'manager', 'reviewer')
+ )
+ AND auth.uid() = actor_id
+ );
+
+-- Create RLS policies for agency_settings
+CREATE POLICY "Agency members can read their agency settings"
+ ON agency_settings FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agency_settings.agency_id
+ AND agency_users.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can update settings"
+ ON agency_settings FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_users.agency_id = agency_settings.agency_id
+ AND agency_users.user_id = auth.uid()
+ AND agency_users.role IN ('owner', 'admin')
+ )
+ );
+
+-- Create helper functions
+CREATE OR REPLACE FUNCTION get_user_agency_role(user_id uuid, agency_id uuid)
+RETURNS agency_role AS $$
+BEGIN
+ RETURN (
+ SELECT role FROM agency_users
+ WHERE agency_users.user_id = $1
+ AND agency_users.agency_id = $2
+ );
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+CREATE OR REPLACE FUNCTION user_has_agency_permission(user_id uuid, agency_id uuid, required_role agency_role)
+RETURNS boolean AS $$
+DECLARE
+ user_role agency_role;
+ role_hierarchy integer;
+ required_hierarchy integer;
+BEGIN
+ -- Get user's role in the agency
+ SELECT role INTO user_role FROM agency_users
+ WHERE agency_users.user_id = $1 AND agency_users.agency_id = $2;
+
+ IF user_role IS NULL THEN
+ RETURN false;
+ END IF;
+
+ -- Define role hierarchy (higher number = more permissions)
+ role_hierarchy := CASE user_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ ELSE 0
+ END;
+
+ required_hierarchy := CASE required_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ ELSE 0
+ END;
+
+ RETURN role_hierarchy >= required_hierarchy;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Create trigger for updated_at timestamps
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Add updated_at triggers
+CREATE TRIGGER update_agencies_updated_at
+ BEFORE UPDATE ON agencies
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_agency_users_updated_at
+ BEFORE UPDATE ON agency_users
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_agency_settings_updated_at
+ BEFORE UPDATE ON agency_settings
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Create trigger to ensure at least one owner per agency
+CREATE OR REPLACE FUNCTION ensure_agency_has_owner()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- If we're deleting or changing an owner role
+ IF (TG_OP = 'DELETE' AND OLD.role = 'owner') OR
+ (TG_OP = 'UPDATE' AND OLD.role = 'owner' AND NEW.role != 'owner') THEN
+
+ -- Check if this would leave the agency without any owners
+ IF NOT EXISTS (
+ SELECT 1 FROM agency_users
+ WHERE agency_id = COALESCE(OLD.agency_id, NEW.agency_id)
+ AND role = 'owner'
+ AND id != COALESCE(OLD.id, NEW.id)
+ ) THEN
+ RAISE EXCEPTION 'Cannot remove the last owner from an agency';
+ END IF;
+ END IF;
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER ensure_agency_has_owner_trigger
+ BEFORE UPDATE OR DELETE ON agency_users
+ FOR EACH ROW EXECUTE FUNCTION ensure_agency_has_owner();
+
+-- Create trigger to auto-create agency settings
+CREATE OR REPLACE FUNCTION create_agency_settings()
+RETURNS TRIGGER AS $$
+BEGIN
+ INSERT INTO agency_settings (agency_id)
+ VALUES (NEW.id)
+ ON CONFLICT (agency_id) DO NOTHING;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER create_agency_settings_trigger
+ AFTER INSERT ON agencies
+ FOR EACH ROW EXECUTE FUNCTION create_agency_settings();
\ No newline at end of file
diff --git a/supabase/migrations/20250727171219_bright_queen.sql b/supabase/migrations/20250727171219_bright_queen.sql
new file mode 100644
index 0000000..2a1c7fb
--- /dev/null
+++ b/supabase/migrations/20250727171219_bright_queen.sql
@@ -0,0 +1,25 @@
+/*
+ # Seed Reference Data
+
+ 1. Tags
+ - Insert predefined topic tags with colors and descriptions
+
+ 2. Sample Data
+ - Create sample agency and users for testing
+*/
+
+-- Insert predefined tags
+INSERT INTO tags (name, description, color) VALUES
+ ('Budget', 'Financial planning and budget proposals', '#10B981'),
+ ('Transportation', 'Roads, transit, and mobility planning', '#3B82F6'),
+ ('Housing', 'Residential development and housing policy', '#8B5CF6'),
+ ('Environment', 'Environmental protection and sustainability', '#059669'),
+ ('Public Safety', 'Police, fire, and emergency services', '#DC2626'),
+ ('Parks & Recreation', 'Parks, facilities, and recreational programs', '#65A30D'),
+ ('Zoning', 'Land use and zoning regulations', '#D97706'),
+ ('Economic Development', 'Business development and economic policy', '#7C3AED'),
+ ('Health', 'Public health and healthcare services', '#DB2777'),
+ ('Education', 'Schools and educational programs', '#2563EB'),
+ ('Infrastructure', 'Utilities, roads, and public works', '#374151'),
+ ('Community Development', 'Neighborhood and community programs', '#F59E0B')
+ON CONFLICT (name) DO NOTHING;
\ No newline at end of file
diff --git a/supabase/migrations/20250727171348_ancient_wildflower.sql b/supabase/migrations/20250727171348_ancient_wildflower.sql
new file mode 100644
index 0000000..6aaafa5
--- /dev/null
+++ b/supabase/migrations/20250727171348_ancient_wildflower.sql
@@ -0,0 +1,322 @@
+/*
+ # Implement Comprehensive Audit System
+
+ 1. Audit Infrastructure
+ - Generic audit function and trigger
+ - Standard audit columns on all main tables
+ - Soft delete support
+
+ 2. Audit Tables
+ - dockets_audit - Track all docket changes
+ - comments_audit - Track comment lifecycle
+ - agency_users_audit - Track role/permission changes
+
+ 3. Security
+ - RLS on audit tables (Admin+ only)
+ - Automatic capture via triggers
+ - Immutable audit records
+
+ 4. Cleanup
+ - Remove unused tags system
+ - Consolidate tag functionality into dockets.tags array
+*/
+
+-- Remove unused tags system (tags are stored as array in dockets.tags)
+DROP TABLE IF EXISTS docket_tags CASCADE;
+DROP TABLE IF EXISTS tags CASCADE;
+
+-- Create generic audit function
+CREATE OR REPLACE FUNCTION create_audit_record()
+RETURNS TRIGGER AS $$
+DECLARE
+ audit_table_name TEXT;
+ old_data JSONB;
+ new_data JSONB;
+ changed_fields JSONB;
+BEGIN
+ -- Determine audit table name
+ audit_table_name := TG_TABLE_NAME || '_audit';
+
+ -- Handle different operations
+ IF TG_OP = 'DELETE' THEN
+ old_data := to_jsonb(OLD);
+ new_data := NULL;
+ changed_fields := old_data;
+ ELSIF TG_OP = 'UPDATE' THEN
+ old_data := to_jsonb(OLD);
+ new_data := to_jsonb(NEW);
+ -- Only include changed fields
+ changed_fields := jsonb_build_object();
+ FOR key IN SELECT jsonb_object_keys(new_data) LOOP
+ IF old_data->key IS DISTINCT FROM new_data->key THEN
+ changed_fields := changed_fields || jsonb_build_object(
+ key, jsonb_build_object(
+ 'old', old_data->key,
+ 'new', new_data->key
+ )
+ );
+ END IF;
+ END LOOP;
+ ELSIF TG_OP = 'INSERT' THEN
+ old_data := NULL;
+ new_data := to_jsonb(NEW);
+ changed_fields := new_data;
+ END IF;
+
+ -- Insert audit record using dynamic SQL
+ EXECUTE format('
+ INSERT INTO %I (
+ record_id, action, actor_id, old_data, new_data, changed_fields, action_timestamp
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)',
+ audit_table_name
+ ) USING
+ COALESCE(NEW.id, OLD.id),
+ TG_OP,
+ auth.uid(),
+ old_data,
+ new_data,
+ changed_fields,
+ now();
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Add standard audit columns to existing tables
+DO $$
+BEGIN
+ -- Add audit columns to profiles if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'created_by') THEN
+ ALTER TABLE profiles
+ ADD COLUMN created_by uuid REFERENCES profiles(id),
+ ADD COLUMN updated_by uuid REFERENCES profiles(id),
+ ADD COLUMN deleted_at timestamptz;
+ END IF;
+
+ -- Add audit columns to agencies if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'agencies' AND column_name = 'created_by') THEN
+ ALTER TABLE agencies
+ ADD COLUMN created_by uuid REFERENCES profiles(id),
+ ADD COLUMN updated_by uuid REFERENCES profiles(id),
+ ADD COLUMN deleted_at timestamptz;
+ END IF;
+
+ -- Add audit columns to agency_members if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'agency_members' AND column_name = 'created_by') THEN
+ ALTER TABLE agency_members
+ ADD COLUMN created_by uuid REFERENCES profiles(id),
+ ADD COLUMN updated_by uuid REFERENCES profiles(id),
+ ADD COLUMN deleted_at timestamptz;
+ END IF;
+
+ -- Add audit columns to dockets if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dockets' AND column_name = 'created_by') THEN
+ ALTER TABLE dockets
+ ADD COLUMN created_by uuid REFERENCES profiles(id),
+ ADD COLUMN updated_by uuid REFERENCES profiles(id),
+ ADD COLUMN deleted_at timestamptz;
+ END IF;
+
+ -- Add audit columns to comments if they don't exist
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comments' AND column_name = 'created_by') THEN
+ ALTER TABLE comments
+ ADD COLUMN created_by uuid REFERENCES profiles(id),
+ ADD COLUMN updated_by uuid REFERENCES profiles(id),
+ ADD COLUMN deleted_at timestamptz;
+ END IF;
+END $$;
+
+-- Create audit tables
+CREATE TABLE IF NOT EXISTS dockets_audit (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id uuid NOT NULL,
+ action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
+ actor_id uuid REFERENCES profiles(id),
+ old_data jsonb,
+ new_data jsonb,
+ changed_fields jsonb,
+ action_timestamp timestamptz DEFAULT now() NOT NULL,
+ created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS comments_audit (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id uuid NOT NULL,
+ action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
+ actor_id uuid REFERENCES profiles(id),
+ old_data jsonb,
+ new_data jsonb,
+ changed_fields jsonb,
+ action_timestamp timestamptz DEFAULT now() NOT NULL,
+ created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS agency_members_audit (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id uuid NOT NULL,
+ action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
+ actor_id uuid REFERENCES profiles(id),
+ old_data jsonb,
+ new_data jsonb,
+ changed_fields jsonb,
+ action_timestamp timestamptz DEFAULT now() NOT NULL,
+ created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS profiles_audit (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id uuid NOT NULL,
+ action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
+ actor_id uuid REFERENCES profiles(id),
+ old_data jsonb,
+ new_data jsonb,
+ changed_fields jsonb,
+ action_timestamp timestamptz DEFAULT now() NOT NULL,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Enable RLS on audit tables
+ALTER TABLE dockets_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE comments_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_members_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE profiles_audit ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies for audit tables (Admin+ only)
+CREATE POLICY "Agency admins can read docket audit logs"
+ ON dockets_audit
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ )
+ );
+
+CREATE POLICY "Agency admins can read comment audit logs"
+ ON comments_audit
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ )
+ );
+
+CREATE POLICY "Agency admins can read member audit logs"
+ ON agency_members_audit
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am1
+ JOIN agency_members am2 ON am2.agency_id = am1.agency_id
+ WHERE am1.id = record_id
+ AND am2.user_id = auth.uid()
+ AND am2.role IN ('owner', 'admin')
+ )
+ );
+
+CREATE POLICY "Agency admins can read profile audit logs"
+ ON profiles_audit
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am
+ WHERE am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ )
+ );
+
+-- Create triggers for automatic audit capture
+CREATE TRIGGER dockets_audit_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+CREATE TRIGGER comments_audit_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON comments
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+CREATE TRIGGER agency_members_audit_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+CREATE TRIGGER profiles_audit_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+-- Create function to update updated_by column
+CREATE OR REPLACE FUNCTION update_audit_columns()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ NEW.updated_by = auth.uid();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Add update triggers for audit columns
+CREATE TRIGGER profiles_update_audit_trigger
+ BEFORE UPDATE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION update_audit_columns();
+
+CREATE TRIGGER agencies_update_audit_trigger
+ BEFORE UPDATE ON agencies
+ FOR EACH ROW EXECUTE FUNCTION update_audit_columns();
+
+CREATE TRIGGER agency_members_update_audit_trigger
+ BEFORE UPDATE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION update_audit_columns();
+
+CREATE TRIGGER dockets_update_audit_trigger
+ BEFORE UPDATE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION update_audit_columns();
+
+CREATE TRIGGER comments_update_audit_trigger
+ BEFORE UPDATE ON comments
+ FOR EACH ROW EXECUTE FUNCTION update_audit_columns();
+
+-- Create indexes for audit tables
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_record_id ON dockets_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_timestamp ON dockets_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_actor ON dockets_audit(actor_id);
+
+CREATE INDEX IF NOT EXISTS idx_comments_audit_record_id ON comments_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_comments_audit_timestamp ON comments_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_comments_audit_actor ON comments_audit(actor_id);
+
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_record_id ON agency_members_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_timestamp ON agency_members_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_actor ON agency_members_audit(actor_id);
+
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_record_id ON profiles_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_timestamp ON profiles_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_actor ON profiles_audit(actor_id);
+
+-- Create soft delete views (exclude deleted records)
+CREATE OR REPLACE VIEW active_dockets AS
+SELECT * FROM dockets WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_comments AS
+SELECT * FROM comments WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_agency_members AS
+SELECT * FROM agency_members WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_profiles AS
+SELECT * FROM profiles WHERE deleted_at IS NULL;
+
+-- Grant permissions on views
+GRANT SELECT ON active_dockets TO authenticated, anon;
+GRANT SELECT ON active_comments TO authenticated, anon;
+GRANT SELECT ON active_agency_members TO authenticated;
+GRANT SELECT ON active_profiles TO authenticated;
\ No newline at end of file
diff --git a/supabase/migrations/20250727171523_maroon_marsh.sql b/supabase/migrations/20250727171523_maroon_marsh.sql
new file mode 100644
index 0000000..1eb9cd9
--- /dev/null
+++ b/supabase/migrations/20250727171523_maroon_marsh.sql
@@ -0,0 +1,310 @@
+/*
+ # Comprehensive Audit System Implementation
+
+ 1. Standard Audit Columns
+ - Add created_at, created_by, updated_at, updated_by, deleted_at to all main tables
+
+ 2. Audit Tables
+ - Create audit tables for dockets, comments, agency_members, profiles
+ - Automatic triggers to capture all changes
+
+ 3. Security
+ - RLS policies for audit tables (Admin+ only)
+ - Soft delete support with active views
+
+ 4. Cleanup
+ - Remove unused tags system
+ - Consolidate into single comprehensive migration
+*/
+
+-- Create audit action enum
+DO $$ BEGIN
+ CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Generic audit function
+CREATE OR REPLACE FUNCTION create_audit_record()
+RETURNS TRIGGER AS $$
+DECLARE
+ audit_table_name TEXT;
+ old_data JSONB;
+ new_data JSONB;
+ changed_fields JSONB;
+BEGIN
+ -- Determine audit table name
+ audit_table_name := TG_TABLE_NAME || '_audit';
+
+ -- Prepare data based on operation
+ CASE TG_OP
+ WHEN 'INSERT' THEN
+ new_data := to_jsonb(NEW);
+ old_data := NULL;
+ changed_fields := new_data;
+ WHEN 'UPDATE' THEN
+ old_data := to_jsonb(OLD);
+ new_data := to_jsonb(NEW);
+ -- Only include changed fields
+ SELECT jsonb_object_agg(key, value)
+ INTO changed_fields
+ FROM jsonb_each(new_data)
+ WHERE old_data->key IS DISTINCT FROM value;
+ WHEN 'DELETE' THEN
+ old_data := to_jsonb(OLD);
+ new_data := NULL;
+ changed_fields := old_data;
+ END CASE;
+
+ -- Insert audit record using dynamic SQL
+ EXECUTE format('
+ INSERT INTO %I (
+ record_id, action, actor_id, old_data, new_data,
+ changed_fields, action_timestamp
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)',
+ audit_table_name
+ ) USING
+ COALESCE(NEW.id, OLD.id),
+ TG_OP::audit_action,
+ COALESCE(NEW.updated_by, NEW.created_by, OLD.updated_by, auth.uid()),
+ old_data,
+ new_data,
+ changed_fields,
+ NOW();
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Update timestamp function
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ NEW.updated_by = auth.uid();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Add audit columns to existing tables
+DO $$
+DECLARE
+ table_name TEXT;
+ tables_to_update TEXT[] := ARRAY['profiles', 'agencies', 'agency_members', 'dockets', 'comments'];
+BEGIN
+ FOREACH table_name IN ARRAY tables_to_update
+ LOOP
+ -- Add created_by if not exists
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = table_name AND column_name = 'created_by'
+ ) THEN
+ EXECUTE format('ALTER TABLE %I ADD COLUMN created_by UUID REFERENCES auth.users(id)', table_name);
+ END IF;
+
+ -- Add updated_by if not exists
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = table_name AND column_name = 'updated_by'
+ ) THEN
+ EXECUTE format('ALTER TABLE %I ADD COLUMN updated_by UUID REFERENCES auth.users(id)', table_name);
+ END IF;
+
+ -- Add deleted_at if not exists
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = table_name AND column_name = 'deleted_at'
+ ) THEN
+ EXECUTE format('ALTER TABLE %I ADD COLUMN deleted_at TIMESTAMPTZ', table_name);
+ END IF;
+ END LOOP;
+END $$;
+
+-- Create audit tables
+CREATE TABLE IF NOT EXISTS dockets_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS comments_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS agency_members_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS profiles_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Add indexes for audit tables
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_record_id ON dockets_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_timestamp ON dockets_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_comments_audit_record_id ON comments_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_comments_audit_timestamp ON comments_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_record_id ON agency_members_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_timestamp ON agency_members_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_record_id ON profiles_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_timestamp ON profiles_audit(action_timestamp);
+
+-- Add audit triggers to main tables
+DROP TRIGGER IF EXISTS audit_dockets_trigger ON dockets;
+CREATE TRIGGER audit_dockets_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+DROP TRIGGER IF EXISTS audit_comments_trigger ON comments;
+CREATE TRIGGER audit_comments_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON comments
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+DROP TRIGGER IF EXISTS audit_agency_members_trigger ON agency_members;
+CREATE TRIGGER audit_agency_members_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+DROP TRIGGER IF EXISTS audit_profiles_trigger ON profiles;
+CREATE TRIGGER audit_profiles_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+-- Add updated_at triggers
+DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
+CREATE TRIGGER update_profiles_updated_at
+ BEFORE UPDATE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_agencies_updated_at ON agencies;
+CREATE TRIGGER update_agencies_updated_at
+ BEFORE UPDATE ON agencies
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_agency_members_updated_at ON agency_members;
+CREATE TRIGGER update_agency_members_updated_at
+ BEFORE UPDATE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_dockets_updated_at ON dockets;
+CREATE TRIGGER update_dockets_updated_at
+ BEFORE UPDATE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_comments_updated_at ON comments;
+CREATE TRIGGER update_comments_updated_at
+ BEFORE UPDATE ON comments
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Enable RLS on audit tables
+ALTER TABLE dockets_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE comments_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_members_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE profiles_audit ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies for audit tables (Admin+ only)
+CREATE POLICY "Agency admins can read docket audit logs"
+ ON dockets_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ )
+ );
+
+CREATE POLICY "Agency admins can read comment audit logs"
+ ON comments_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ )
+ );
+
+CREATE POLICY "Agency admins can read member audit logs"
+ ON agency_members_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am1
+ JOIN agency_members am2 ON am2.agency_id = am1.agency_id
+ WHERE am1.id = record_id
+ AND am2.user_id = auth.uid()
+ AND am2.role IN ('owner', 'admin')
+ )
+ );
+
+CREATE POLICY "Users can read own profile audit logs"
+ ON profiles_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM profiles p
+ WHERE p.id = record_id AND p.id = auth.uid()
+ )
+ );
+
+-- Create active views (excluding soft-deleted records)
+CREATE OR REPLACE VIEW active_profiles AS
+SELECT * FROM profiles WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_agencies AS
+SELECT * FROM agencies WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_agency_members AS
+SELECT * FROM agency_members WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_dockets AS
+SELECT * FROM dockets WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_comments AS
+SELECT * FROM comments WHERE deleted_at IS NULL;
+
+-- Remove unused tags tables if they exist
+DROP TABLE IF EXISTS docket_tags CASCADE;
+DROP TABLE IF EXISTS tags CASCADE;
+DROP TABLE IF EXISTS docket_tags CASCADE;
+
+-- Clean up any unused indexes
+DROP INDEX IF EXISTS idx_docket_tags_docket_id;
+DROP INDEX IF EXISTS idx_docket_tags_tag_id;
+DROP INDEX IF EXISTS idx_tags_name;
\ No newline at end of file
diff --git a/supabase/migrations/20250727172041_tight_glitter.sql b/supabase/migrations/20250727172041_tight_glitter.sql
new file mode 100644
index 0000000..6ad2e21
--- /dev/null
+++ b/supabase/migrations/20250727172041_tight_glitter.sql
@@ -0,0 +1,256 @@
+/*
+ # Moderation Queue Setup
+
+ 1. Storage Setup
+ - Create comment-attachments bucket
+ - Set up proper RLS policies for file access
+
+ 2. Tables
+ - Update comments table with status column
+ - Create comment_attachments table
+ - Add moderation_logs table
+
+ 3. Security
+ - RLS policies for moderation access
+ - File access controls
+
+ 4. Triggers
+ - Audit logging for status changes
+ - Auto-update timestamps
+*/
+
+-- Create storage bucket for comment attachments
+INSERT INTO storage.buckets (id, name, public)
+VALUES ('comment-attachments', 'comment-attachments', false)
+ON CONFLICT (id) DO NOTHING;
+
+-- Storage policies for comment attachments
+CREATE POLICY "Agency members can view attachments"
+ON storage.objects FOR SELECT
+TO authenticated
+USING (
+ bucket_id = 'comment-attachments' AND
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE am.user_id = auth.uid()
+ AND (storage.foldername(name))[3] = c.id::text
+ )
+);
+
+CREATE POLICY "Public can upload attachments"
+ON storage.objects FOR INSERT
+TO authenticated
+WITH CHECK (
+ bucket_id = 'comment-attachments' AND
+ (storage.foldername(name))[1] = 'agency'
+);
+
+-- Update comments table with status if not exists
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'status'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN status comment_status DEFAULT 'pending';
+ END IF;
+END $$;
+
+-- Create comment_attachments table if not exists
+CREATE TABLE IF NOT EXISTS comment_attachments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ filename TEXT NOT NULL,
+ file_url TEXT NOT NULL,
+ file_path TEXT NOT NULL,
+ mime_type TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ uploaded_at TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Create indexes for comment_attachments
+CREATE INDEX IF NOT EXISTS idx_comment_attachments_comment_id ON comment_attachments(comment_id);
+CREATE INDEX IF NOT EXISTS idx_comment_attachments_uploaded_at ON comment_attachments(uploaded_at);
+
+-- Enable RLS on comment_attachments
+ALTER TABLE comment_attachments ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies for comment_attachments
+CREATE POLICY "Agency members can read comment attachments"
+ON comment_attachments FOR SELECT
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = comment_attachments.comment_id
+ AND am.user_id = auth.uid()
+ )
+);
+
+CREATE POLICY "Users can create attachments for their comments"
+ON comment_attachments FOR INSERT
+TO authenticated
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments c
+ WHERE c.id = comment_attachments.comment_id
+ AND c.user_id = auth.uid()
+ )
+);
+
+-- Create moderation_logs table if not exists
+CREATE TABLE IF NOT EXISTS moderation_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ action moderation_action NOT NULL,
+ actor_id UUID NOT NULL REFERENCES auth.users(id),
+ previous_status comment_status,
+ new_status comment_status,
+ reason TEXT,
+ notes TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Create indexes for moderation_logs
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_comment_id ON moderation_logs(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_actor_id ON moderation_logs(actor_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_created_at ON moderation_logs(created_at);
+
+-- Enable RLS on moderation_logs
+ALTER TABLE moderation_logs ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies for moderation_logs
+CREATE POLICY "Agency members can read moderation logs for their dockets"
+ON moderation_logs FOR SELECT
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = moderation_logs.comment_id
+ AND am.user_id = auth.uid()
+ )
+);
+
+CREATE POLICY "Agency reviewers+ can create moderation logs"
+ON moderation_logs FOR INSERT
+TO authenticated
+WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = moderation_logs.comment_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin', 'manager', 'reviewer')
+ ) AND actor_id = auth.uid()
+);
+
+-- Function to log moderation actions
+CREATE OR REPLACE FUNCTION log_comment_moderation()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Only log status changes
+ IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN
+ INSERT INTO moderation_logs (
+ comment_id,
+ action,
+ actor_id,
+ previous_status,
+ new_status
+ ) VALUES (
+ NEW.id,
+ CASE NEW.status
+ WHEN 'approved' THEN 'approve'
+ WHEN 'rejected' THEN 'reject'
+ WHEN 'flagged' THEN 'flag'
+ ELSE 'edit'
+ END::moderation_action,
+ COALESCE(NEW.updated_by, auth.uid()),
+ OLD.status,
+ NEW.status
+ );
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Create trigger for comment moderation logging
+DROP TRIGGER IF EXISTS log_comment_moderation_trigger ON comments;
+CREATE TRIGGER log_comment_moderation_trigger
+ AFTER UPDATE ON comments
+ FOR EACH ROW EXECUTE FUNCTION log_comment_moderation();
+
+-- Function to get signed URL for attachment
+CREATE OR REPLACE FUNCTION get_attachment_signed_url(attachment_id UUID)
+RETURNS TEXT AS $$
+DECLARE
+ file_path TEXT;
+BEGIN
+ SELECT comment_attachments.file_path INTO file_path
+ FROM comment_attachments
+ WHERE id = attachment_id;
+
+ IF file_path IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- Return the file path - signed URL generation will be handled by the client
+ RETURN file_path;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Update comments RLS policies for moderation access
+DROP POLICY IF EXISTS "Agency reviewers+ can update comment status" ON comments;
+CREATE POLICY "Agency reviewers+ can update comment status"
+ON comments FOR UPDATE
+TO authenticated
+USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = comments.docket_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin', 'manager', 'reviewer')
+ )
+);
+
+-- Add helpful view for moderation queue
+CREATE OR REPLACE VIEW moderation_queue AS
+SELECT
+ c.id,
+ c.docket_id,
+ c.content,
+ c.status,
+ c.commenter_name,
+ c.commenter_email,
+ c.commenter_organization,
+ c.created_at,
+ c.updated_at,
+ d.title as docket_title,
+ d.agency_id,
+ COUNT(ca.id) as attachment_count,
+ ARRAY_AGG(
+ CASE WHEN ca.id IS NOT NULL THEN
+ jsonb_build_object(
+ 'id', ca.id,
+ 'filename', ca.filename,
+ 'file_size', ca.file_size,
+ 'mime_type', ca.mime_type
+ )
+ END
+ ) FILTER (WHERE ca.id IS NOT NULL) as attachments
+FROM comments c
+JOIN dockets d ON d.id = c.docket_id
+LEFT JOIN comment_attachments ca ON ca.comment_id = c.id
+WHERE c.deleted_at IS NULL
+GROUP BY c.id, c.docket_id, c.content, c.status, c.commenter_name,
+ c.commenter_email, c.commenter_organization, c.created_at,
+ c.updated_at, d.title, d.agency_id;
\ No newline at end of file
diff --git a/supabase/migrations/20250727172444_aged_grove.sql b/supabase/migrations/20250727172444_aged_grove.sql
new file mode 100644
index 0000000..5ebc4da
--- /dev/null
+++ b/supabase/migrations/20250727172444_aged_grove.sql
@@ -0,0 +1,579 @@
+/*
+ # Comprehensive Schema Update with Audit System and Moderation Queue
+
+ This migration implements:
+ 1. Complete audit system with proper column additions
+ 2. Moderation queue functionality
+ 3. File upload system with Supabase Storage
+ 4. Role-based permissions and RLS policies
+ 5. Cleanup of any previous migration issues
+
+ ## Changes Made
+ - Add audit columns to all main tables
+ - Create audit tables with proper triggers
+ - Implement moderation queue with comment status tracking
+ - Set up file attachment system
+ - Create storage bucket and policies
+ - Add comprehensive RLS policies
+ - Clean up any unused/problematic tables
+*/
+
+-- First, clean up any problematic existing objects
+DROP TABLE IF EXISTS tags CASCADE;
+DROP TABLE IF EXISTS docket_tags CASCADE;
+DROP VIEW IF EXISTS active_profiles CASCADE;
+DROP VIEW IF EXISTS active_agencies CASCADE;
+DROP VIEW IF EXISTS active_agency_members CASCADE;
+DROP VIEW IF EXISTS active_dockets CASCADE;
+DROP VIEW IF EXISTS active_comments CASCADE;
+
+-- Drop any existing audit tables to recreate them properly
+DROP TABLE IF EXISTS dockets_audit CASCADE;
+DROP TABLE IF EXISTS comments_audit CASCADE;
+DROP TABLE IF EXISTS agency_members_audit CASCADE;
+DROP TABLE IF EXISTS profiles_audit CASCADE;
+
+-- Drop existing triggers to avoid conflicts
+DROP TRIGGER IF EXISTS audit_dockets_trigger ON dockets;
+DROP TRIGGER IF EXISTS audit_comments_trigger ON comments;
+DROP TRIGGER IF EXISTS audit_agency_members_trigger ON agency_members;
+DROP TRIGGER IF EXISTS audit_profiles_trigger ON profiles;
+DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
+DROP TRIGGER IF EXISTS update_agencies_updated_at ON agencies;
+DROP TRIGGER IF EXISTS update_agency_members_updated_at ON agency_members;
+DROP TRIGGER IF EXISTS update_dockets_updated_at ON dockets;
+DROP TRIGGER IF EXISTS update_comments_updated_at ON comments;
+
+-- Create required enums
+DO $$ BEGIN
+ CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+DO $$ BEGIN
+ CREATE TYPE comment_status AS ENUM ('pending', 'approved', 'rejected', 'flagged');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+DO $$ BEGIN
+ CREATE TYPE moderation_action AS ENUM ('approve', 'reject', 'flag', 'unflag', 'edit', 'delete');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Helper function to get current user ID
+CREATE OR REPLACE FUNCTION uid() RETURNS UUID AS $$
+BEGIN
+ RETURN auth.uid();
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Generic audit function
+CREATE OR REPLACE FUNCTION create_audit_record()
+RETURNS TRIGGER AS $$
+DECLARE
+ audit_table_name TEXT;
+ old_data JSONB;
+ new_data JSONB;
+ changed_fields JSONB;
+ current_user_id UUID;
+BEGIN
+ -- Get current user ID
+ current_user_id := uid();
+
+ -- Determine audit table name
+ audit_table_name := TG_TABLE_NAME || '_audit';
+
+ -- Prepare data based on operation
+ CASE TG_OP
+ WHEN 'INSERT' THEN
+ new_data := to_jsonb(NEW);
+ old_data := NULL;
+ changed_fields := new_data;
+ WHEN 'UPDATE' THEN
+ old_data := to_jsonb(OLD);
+ new_data := to_jsonb(NEW);
+ -- Only include changed fields
+ SELECT jsonb_object_agg(key, value)
+ INTO changed_fields
+ FROM jsonb_each(new_data)
+ WHERE old_data->key IS DISTINCT FROM value;
+ WHEN 'DELETE' THEN
+ old_data := to_jsonb(OLD);
+ new_data := NULL;
+ changed_fields := old_data;
+ END CASE;
+
+ -- Insert audit record using dynamic SQL
+ EXECUTE format('
+ INSERT INTO %I (
+ record_id, action, actor_id, old_data, new_data,
+ changed_fields, action_timestamp
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)',
+ audit_table_name
+ ) USING
+ COALESCE(NEW.id, OLD.id),
+ TG_OP::audit_action,
+ current_user_id,
+ old_data,
+ new_data,
+ changed_fields,
+ NOW();
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Update timestamp function
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ NEW.updated_by = uid();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Add audit columns to existing tables (using explicit table names to avoid ambiguity)
+-- Profiles table
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'created_by'
+ ) THEN
+ ALTER TABLE profiles ADD COLUMN created_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'updated_by'
+ ) THEN
+ ALTER TABLE profiles ADD COLUMN updated_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'deleted_at'
+ ) THEN
+ ALTER TABLE profiles ADD COLUMN deleted_at TIMESTAMPTZ;
+ END IF;
+END $$;
+
+-- Agencies table
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'created_by'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN created_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'updated_by'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN updated_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'deleted_at'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN deleted_at TIMESTAMPTZ;
+ END IF;
+END $$;
+
+-- Agency_members table
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agency_members' AND column_name = 'created_by'
+ ) THEN
+ ALTER TABLE agency_members ADD COLUMN created_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agency_members' AND column_name = 'updated_by'
+ ) THEN
+ ALTER TABLE agency_members ADD COLUMN updated_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agency_members' AND column_name = 'deleted_at'
+ ) THEN
+ ALTER TABLE agency_members ADD COLUMN deleted_at TIMESTAMPTZ;
+ END IF;
+END $$;
+
+-- Dockets table
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'dockets' AND column_name = 'created_by'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN created_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'dockets' AND column_name = 'updated_by'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN updated_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'dockets' AND column_name = 'deleted_at'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN deleted_at TIMESTAMPTZ;
+ END IF;
+END $$;
+
+-- Comments table (add status column and audit columns)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'comments' AND column_name = 'status'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN status comment_status DEFAULT 'pending';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'comments' AND column_name = 'created_by'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN created_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'comments' AND column_name = 'updated_by'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN updated_by UUID REFERENCES auth.users(id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'comments' AND column_name = 'deleted_at'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN deleted_at TIMESTAMPTZ;
+ END IF;
+END $$;
+
+-- Create comment_attachments table
+CREATE TABLE IF NOT EXISTS comment_attachments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ filename TEXT NOT NULL,
+ file_url TEXT NOT NULL,
+ file_path TEXT NOT NULL,
+ mime_type TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ created_by UUID REFERENCES auth.users(id),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES auth.users(id),
+ deleted_at TIMESTAMPTZ
+);
+
+-- Create moderation_logs table
+CREATE TABLE IF NOT EXISTS moderation_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ action moderation_action NOT NULL,
+ actor_id UUID NOT NULL REFERENCES auth.users(id),
+ previous_status comment_status,
+ new_status comment_status,
+ reason TEXT,
+ notes TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Create audit tables
+CREATE TABLE IF NOT EXISTS dockets_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS comments_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS agency_members_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS profiles_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_comment_attachments_comment_id ON comment_attachments(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_comment_id ON moderation_logs(comment_id);
+CREATE INDEX IF NOT EXISTS idx_moderation_logs_actor_id ON moderation_logs(actor_id);
+CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status);
+CREATE INDEX IF NOT EXISTS idx_comments_docket_id ON comments(docket_id);
+
+-- Audit table indexes
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_record_id ON dockets_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_dockets_audit_timestamp ON dockets_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_comments_audit_record_id ON comments_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_comments_audit_timestamp ON comments_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_record_id ON agency_members_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_agency_members_audit_timestamp ON agency_members_audit(action_timestamp);
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_record_id ON profiles_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_profiles_audit_timestamp ON profiles_audit(action_timestamp);
+
+-- Enable RLS on all tables
+ALTER TABLE comment_attachments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE moderation_logs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE dockets_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE comments_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE agency_members_audit ENABLE ROW LEVEL SECURITY;
+ALTER TABLE profiles_audit ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies for comment_attachments
+CREATE POLICY "Users can read attachments for accessible comments"
+ ON comment_attachments FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = comment_id
+ AND am.user_id = auth.uid()
+ AND am.deleted_at IS NULL
+ )
+ OR
+ EXISTS (
+ SELECT 1 FROM comments c
+ WHERE c.id = comment_id AND c.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Users can insert attachments for their comments"
+ ON comment_attachments FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments c
+ WHERE c.id = comment_id AND c.user_id = auth.uid()
+ )
+ );
+
+-- RLS Policies for moderation_logs
+CREATE POLICY "Agency reviewers can read moderation logs"
+ ON moderation_logs FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = comment_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin', 'manager', 'reviewer')
+ AND am.deleted_at IS NULL
+ )
+ );
+
+CREATE POLICY "Agency reviewers can create moderation logs"
+ ON moderation_logs FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = comment_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin', 'manager', 'reviewer')
+ AND am.deleted_at IS NULL
+ )
+ AND actor_id = auth.uid()
+ );
+
+-- RLS Policies for audit tables (Admin+ only)
+CREATE POLICY "Agency admins can read docket audit logs"
+ ON dockets_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ AND am.deleted_at IS NULL
+ )
+ );
+
+CREATE POLICY "Agency admins can read comment audit logs"
+ ON comments_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ AND am.deleted_at IS NULL
+ )
+ );
+
+CREATE POLICY "Agency admins can read member audit logs"
+ ON agency_members_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am1
+ JOIN agency_members am2 ON am2.agency_id = am1.agency_id
+ WHERE am1.id = record_id
+ AND am2.user_id = auth.uid()
+ AND am2.role IN ('owner', 'admin')
+ AND am1.deleted_at IS NULL
+ AND am2.deleted_at IS NULL
+ )
+ );
+
+CREATE POLICY "Users can read own profile audit logs"
+ ON profiles_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM profiles p
+ WHERE p.id = record_id AND p.id = auth.uid()
+ )
+ );
+
+-- Add audit triggers to main tables
+CREATE TRIGGER audit_dockets_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+CREATE TRIGGER audit_comments_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON comments
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+CREATE TRIGGER audit_agency_members_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+CREATE TRIGGER audit_profiles_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+-- Add updated_at triggers
+CREATE TRIGGER update_profiles_updated_at
+ BEFORE UPDATE ON profiles
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_agencies_updated_at
+ BEFORE UPDATE ON agencies
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_agency_members_updated_at
+ BEFORE UPDATE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_dockets_updated_at
+ BEFORE UPDATE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_comments_updated_at
+ BEFORE UPDATE ON comments
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_comment_attachments_updated_at
+ BEFORE UPDATE ON comment_attachments
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Create moderation queue view for easier querying
+CREATE OR REPLACE VIEW moderation_queue AS
+SELECT
+ c.id,
+ c.docket_id,
+ c.content,
+ c.status,
+ c.commenter_name,
+ c.commenter_email,
+ c.commenter_organization,
+ c.created_at,
+ c.updated_at,
+ d.title as docket_title,
+ d.agency_id,
+ COALESCE(att.attachment_count, 0) as attachment_count
+FROM comments c
+JOIN dockets d ON d.id = c.docket_id
+LEFT JOIN (
+ SELECT
+ comment_id,
+ COUNT(*) as attachment_count
+ FROM comment_attachments
+ WHERE deleted_at IS NULL
+ GROUP BY comment_id
+) att ON att.comment_id = c.id
+WHERE c.deleted_at IS NULL
+AND d.deleted_at IS NULL;
+
+-- Create storage bucket for comment attachments (this will be handled by the application)
+-- The bucket creation and policies will be set up via the Supabase dashboard or API
+
+-- Create active views (excluding soft-deleted records)
+CREATE OR REPLACE VIEW active_profiles AS
+SELECT * FROM profiles WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_agencies AS
+SELECT * FROM agencies WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_agency_members AS
+SELECT * FROM agency_members WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_dockets AS
+SELECT * FROM dockets WHERE deleted_at IS NULL;
+
+CREATE OR REPLACE VIEW active_comments AS
+SELECT * FROM comments WHERE deleted_at IS NULL;
\ No newline at end of file
diff --git a/supabase/migrations/20250727172631_floral_breeze.sql b/supabase/migrations/20250727172631_floral_breeze.sql
new file mode 100644
index 0000000..a1e9bd1
--- /dev/null
+++ b/supabase/migrations/20250727172631_floral_breeze.sql
@@ -0,0 +1,529 @@
+/*
+ # User & Role Administration System
+
+ 1. New Tables
+ - `agency_invitations` - Track pending invites
+ - Enhanced `agency_members` with status tracking
+
+ 2. Security
+ - RLS policies for user management
+ - Role-based access controls
+ - Audit logging for permission changes
+
+ 3. Functions
+ - Invite user functionality
+ - Role change validation
+ - Status management
+*/
+
+-- Add status tracking to agency_members if not exists
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'agency_members'
+ AND column_name = 'status'
+ ) THEN
+ ALTER TABLE agency_members ADD COLUMN status TEXT DEFAULT 'active' CHECK (status IN ('active', 'pending', 'deactivated'));
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'agency_members'
+ AND column_name = 'invited_at'
+ ) THEN
+ ALTER TABLE agency_members ADD COLUMN invited_at TIMESTAMPTZ;
+ END IF;
+END $$;
+
+-- Create agency invitations table
+CREATE TABLE IF NOT EXISTS agency_invitations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ role agency_role NOT NULL DEFAULT 'reviewer',
+ invited_by UUID NOT NULL REFERENCES auth.users(id),
+ token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
+ expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
+ accepted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ created_by UUID REFERENCES auth.users(id),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES auth.users(id),
+ deleted_at TIMESTAMPTZ
+);
+
+-- Add indexes for agency invitations
+CREATE INDEX IF NOT EXISTS idx_agency_invitations_agency_id ON agency_invitations(agency_id);
+CREATE INDEX IF NOT EXISTS idx_agency_invitations_email ON agency_invitations(email);
+CREATE INDEX IF NOT EXISTS idx_agency_invitations_token ON agency_invitations(token);
+CREATE INDEX IF NOT EXISTS idx_agency_invitations_expires_at ON agency_invitations(expires_at);
+
+-- Enable RLS on agency invitations
+ALTER TABLE agency_invitations ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies for agency invitations
+CREATE POLICY "Agency owners and admins can manage invitations"
+ ON agency_invitations FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am
+ WHERE am.agency_id = agency_invitations.agency_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ AND am.status = 'active'
+ )
+ );
+
+-- Update agency_members RLS to include status check
+DROP POLICY IF EXISTS "Agency owners and admins can manage members" ON agency_members;
+CREATE POLICY "Agency owners and admins can manage members"
+ ON agency_members FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am2
+ WHERE am2.agency_id = agency_members.agency_id
+ AND am2.user_id = auth.uid()
+ AND am2.role IN ('owner', 'admin')
+ AND am2.status = 'active'
+ )
+ );
+
+DROP POLICY IF EXISTS "Users can read agency members for their agencies" ON agency_members;
+CREATE POLICY "Users can read agency members for their agencies"
+ ON agency_members FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members am2
+ WHERE am2.agency_id = agency_members.agency_id
+ AND am2.user_id = auth.uid()
+ AND am2.status = 'active'
+ )
+ );
+
+-- Function to invite user to agency
+CREATE OR REPLACE FUNCTION invite_user_to_agency(
+ p_agency_id UUID,
+ p_email TEXT,
+ p_role agency_role DEFAULT 'reviewer'
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_invitation_id UUID;
+ v_user_role agency_role;
+BEGIN
+ -- Check if current user can invite (owner or admin)
+ SELECT role INTO v_user_role
+ FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = auth.uid()
+ AND status = 'active';
+
+ IF v_user_role NOT IN ('owner', 'admin') THEN
+ RAISE EXCEPTION 'Insufficient permissions to invite users';
+ END IF;
+
+ -- Check if user is already a member
+ IF EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = (SELECT id FROM auth.users WHERE email = p_email)
+ ) THEN
+ RAISE EXCEPTION 'User is already a member of this agency';
+ END IF;
+
+ -- Check if there's already a pending invitation
+ IF EXISTS (
+ SELECT 1 FROM agency_invitations
+ WHERE agency_id = p_agency_id
+ AND email = p_email
+ AND accepted_at IS NULL
+ AND expires_at > NOW()
+ ) THEN
+ RAISE EXCEPTION 'User already has a pending invitation';
+ END IF;
+
+ -- Create invitation
+ INSERT INTO agency_invitations (
+ agency_id,
+ email,
+ role,
+ invited_by,
+ created_by,
+ updated_by
+ ) VALUES (
+ p_agency_id,
+ p_email,
+ p_role,
+ auth.uid(),
+ auth.uid(),
+ auth.uid()
+ ) RETURNING id INTO v_invitation_id;
+
+ RETURN v_invitation_id;
+END;
+$$;
+
+-- Function to accept agency invitation
+CREATE OR REPLACE FUNCTION accept_agency_invitation(p_token TEXT)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_invitation agency_invitations%ROWTYPE;
+ v_member_id UUID;
+ v_user_id UUID;
+BEGIN
+ -- Get current user
+ v_user_id := auth.uid();
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'User must be authenticated';
+ END IF;
+
+ -- Get invitation
+ SELECT * INTO v_invitation
+ FROM agency_invitations
+ WHERE token = p_token
+ AND accepted_at IS NULL
+ AND expires_at > NOW();
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Invalid or expired invitation';
+ END IF;
+
+ -- Check if user email matches invitation
+ IF NOT EXISTS (
+ SELECT 1 FROM auth.users
+ WHERE id = v_user_id
+ AND email = v_invitation.email
+ ) THEN
+ RAISE EXCEPTION 'User email does not match invitation';
+ END IF;
+
+ -- Create agency membership
+ INSERT INTO agency_members (
+ agency_id,
+ user_id,
+ role,
+ invited_by,
+ joined_at,
+ invited_at,
+ status,
+ created_by,
+ updated_by
+ ) VALUES (
+ v_invitation.agency_id,
+ v_user_id,
+ v_invitation.role,
+ v_invitation.invited_by,
+ NOW(),
+ v_invitation.created_at,
+ 'active',
+ v_user_id,
+ v_user_id
+ ) RETURNING id INTO v_member_id;
+
+ -- Mark invitation as accepted
+ UPDATE agency_invitations
+ SET accepted_at = NOW(),
+ updated_at = NOW(),
+ updated_by = v_user_id
+ WHERE id = v_invitation.id;
+
+ RETURN v_member_id;
+END;
+$$;
+
+-- Function to change user role
+CREATE OR REPLACE FUNCTION change_user_role(
+ p_member_id UUID,
+ p_new_role agency_role
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_member agency_members%ROWTYPE;
+ v_current_user_role agency_role;
+ v_role_levels INTEGER[] := ARRAY[1, 2, 3, 4, 5]; -- viewer, reviewer, manager, admin, owner
+ v_current_level INTEGER;
+ v_new_level INTEGER;
+ v_user_level INTEGER;
+BEGIN
+ -- Get member details
+ SELECT * INTO v_member
+ FROM agency_members
+ WHERE id = p_member_id;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Member not found';
+ END IF;
+
+ -- Get current user's role
+ SELECT role INTO v_current_user_role
+ FROM agency_members
+ WHERE agency_id = v_member.agency_id
+ AND user_id = auth.uid()
+ AND status = 'active';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Insufficient permissions';
+ END IF;
+
+ -- Map roles to levels
+ v_current_level := CASE v_member.role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ v_new_level := CASE p_new_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ v_user_level := CASE v_current_user_role
+ WHEN 'viewer' THEN 1
+ WHEN 'reviewer' THEN 2
+ WHEN 'manager' THEN 3
+ WHEN 'admin' THEN 4
+ WHEN 'owner' THEN 5
+ END;
+
+ -- Check permissions
+ IF v_current_user_role = 'owner' THEN
+ -- Owners can change anyone's role
+ NULL;
+ ELSIF v_current_user_role = 'admin' THEN
+ -- Admins can only change roles up to manager level
+ IF v_new_level > 3 OR v_current_level > 3 THEN
+ RAISE EXCEPTION 'Admins can only manage up to Manager level';
+ END IF;
+ ELSE
+ RAISE EXCEPTION 'Insufficient permissions to change roles';
+ END IF;
+
+ -- Prevent removing the last owner
+ IF v_member.role = 'owner' AND p_new_role != 'owner' THEN
+ IF (SELECT COUNT(*) FROM agency_members
+ WHERE agency_id = v_member.agency_id
+ AND role = 'owner'
+ AND status = 'active') <= 1 THEN
+ RAISE EXCEPTION 'Cannot remove the last owner';
+ END IF;
+ END IF;
+
+ -- Update role
+ UPDATE agency_members
+ SET role = p_new_role,
+ updated_at = NOW(),
+ updated_by = auth.uid()
+ WHERE id = p_member_id;
+
+ RETURN TRUE;
+END;
+$$;
+
+-- Function to deactivate/reactivate user
+CREATE OR REPLACE FUNCTION change_user_status(
+ p_member_id UUID,
+ p_status TEXT
+)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_member agency_members%ROWTYPE;
+ v_current_user_role agency_role;
+BEGIN
+ -- Validate status
+ IF p_status NOT IN ('active', 'deactivated') THEN
+ RAISE EXCEPTION 'Invalid status. Must be active or deactivated';
+ END IF;
+
+ -- Get member details
+ SELECT * INTO v_member
+ FROM agency_members
+ WHERE id = p_member_id;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Member not found';
+ END IF;
+
+ -- Get current user's role
+ SELECT role INTO v_current_user_role
+ FROM agency_members
+ WHERE agency_id = v_member.agency_id
+ AND user_id = auth.uid()
+ AND status = 'active';
+
+ IF v_current_user_role NOT IN ('owner', 'admin') THEN
+ RAISE EXCEPTION 'Insufficient permissions';
+ END IF;
+
+ -- Prevent deactivating the last owner
+ IF v_member.role = 'owner' AND p_status = 'deactivated' THEN
+ IF (SELECT COUNT(*) FROM agency_members
+ WHERE agency_id = v_member.agency_id
+ AND role = 'owner'
+ AND status = 'active') <= 1 THEN
+ RAISE EXCEPTION 'Cannot deactivate the last owner';
+ END IF;
+ END IF;
+
+ -- Update status
+ UPDATE agency_members
+ SET status = p_status,
+ updated_at = NOW(),
+ updated_by = auth.uid()
+ WHERE id = p_member_id;
+
+ RETURN TRUE;
+END;
+$$;
+
+-- Function to resend invitation
+CREATE OR REPLACE FUNCTION resend_agency_invitation(p_invitation_id UUID)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_invitation agency_invitations%ROWTYPE;
+ v_current_user_role agency_role;
+BEGIN
+ -- Get invitation
+ SELECT * INTO v_invitation
+ FROM agency_invitations
+ WHERE id = p_invitation_id;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Invitation not found';
+ END IF;
+
+ -- Check permissions
+ SELECT role INTO v_current_user_role
+ FROM agency_members
+ WHERE agency_id = v_invitation.agency_id
+ AND user_id = auth.uid()
+ AND status = 'active';
+
+ IF v_current_user_role NOT IN ('owner', 'admin') THEN
+ RAISE EXCEPTION 'Insufficient permissions';
+ END IF;
+
+ -- Update invitation expiry
+ UPDATE agency_invitations
+ SET expires_at = NOW() + INTERVAL '7 days',
+ updated_at = NOW(),
+ updated_by = auth.uid()
+ WHERE id = p_invitation_id;
+
+ RETURN TRUE;
+END;
+$$;
+
+-- Add audit trigger for agency_members
+DROP TRIGGER IF EXISTS audit_agency_members_trigger ON agency_members;
+CREATE TRIGGER audit_agency_members_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON agency_members
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+-- Add audit trigger for agency_invitations
+DROP TRIGGER IF EXISTS audit_agency_invitations_trigger ON agency_invitations;
+CREATE TRIGGER audit_agency_invitations_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON agency_invitations
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+-- Create agency_invitations_audit table
+CREATE TABLE IF NOT EXISTS agency_invitations_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Add indexes for agency_invitations_audit
+CREATE INDEX IF NOT EXISTS idx_agency_invitations_audit_record_id ON agency_invitations_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_agency_invitations_audit_timestamp ON agency_invitations_audit(action_timestamp);
+
+-- Enable RLS on agency_invitations_audit
+ALTER TABLE agency_invitations_audit ENABLE ROW LEVEL SECURITY;
+
+-- RLS policy for agency_invitations_audit
+CREATE POLICY "Agency admins can read invitation audit logs"
+ ON agency_invitations_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_invitations ai
+ JOIN agency_members am ON am.agency_id = ai.agency_id
+ WHERE ai.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ AND am.status = 'active'
+ )
+ );
+
+-- Update existing RLS policies to check status = 'active'
+DROP POLICY IF EXISTS "Agency members can read dockets" ON dockets;
+CREATE POLICY "Agency members can read dockets"
+ ON dockets FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_id = dockets.agency_id
+ AND user_id = auth.uid()
+ AND status = 'active'
+ )
+ );
+
+DROP POLICY IF EXISTS "Agency managers+ can create dockets" ON dockets;
+CREATE POLICY "Agency managers+ can create dockets"
+ ON dockets FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_id = dockets.agency_id
+ AND user_id = auth.uid()
+ AND role IN ('owner', 'admin', 'manager')
+ AND status = 'active'
+ )
+ );
+
+DROP POLICY IF EXISTS "Agency managers+ can update dockets" ON dockets;
+CREATE POLICY "Agency managers+ can update dockets"
+ ON dockets FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_id = dockets.agency_id
+ AND user_id = auth.uid()
+ AND role IN ('owner', 'admin', 'manager')
+ AND status = 'active'
+ )
+ );
\ No newline at end of file
diff --git a/supabase/migrations/20250727173035_dusty_fog.sql b/supabase/migrations/20250727173035_dusty_fog.sql
new file mode 100644
index 0000000..dc7dff8
--- /dev/null
+++ b/supabase/migrations/20250727173035_dusty_fog.sql
@@ -0,0 +1,277 @@
+/*
+ # Agency Settings Implementation
+
+ 1. New Tables
+ - `agency_settings` - Configuration settings for each agency
+ - Enhanced `agencies` table with profile fields
+
+ 2. Security
+ - Enable RLS on agency_settings table
+ - Add policies for Owner/Admin edit access
+ - Add audit triggers for all changes
+
+ 3. Features
+ - Agency profile management
+ - Comment window defaults
+ - File upload settings
+ - Branding configuration
+*/
+
+-- Add profile columns to agencies table if not exists
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'logo_url'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN logo_url TEXT;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'contact_email'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN contact_email TEXT;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'jurisdiction_type'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN jurisdiction_type TEXT CHECK (jurisdiction_type IN ('state', 'county', 'city', 'district', 'other'));
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'agencies' AND column_name = 'public_slug'
+ ) THEN
+ ALTER TABLE agencies ADD COLUMN public_slug TEXT UNIQUE;
+ END IF;
+END $$;
+
+-- Create agency_settings table
+CREATE TABLE IF NOT EXISTS agency_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+
+ -- Comment defaults
+ max_file_size_mb INTEGER DEFAULT 10 CHECK (max_file_size_mb > 0 AND max_file_size_mb <= 100),
+ allowed_mime_types TEXT[] DEFAULT ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/jpeg', 'image/png'],
+ captcha_enabled BOOLEAN DEFAULT true,
+ auto_publish BOOLEAN DEFAULT false,
+
+ -- Branding
+ accent_color TEXT DEFAULT '#0050D8',
+ footer_disclaimer TEXT,
+
+ -- Metadata
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ created_by UUID REFERENCES auth.users(id),
+ updated_by UUID REFERENCES auth.users(id),
+ deleted_at TIMESTAMPTZ,
+
+ UNIQUE(agency_id)
+);
+
+-- Create indexes
+CREATE INDEX IF NOT EXISTS idx_agency_settings_agency_id ON agency_settings(agency_id);
+
+-- Enable RLS
+ALTER TABLE agency_settings ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies for agency_settings
+CREATE POLICY "Agency members can read settings"
+ ON agency_settings FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = agency_settings.agency_id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.status = 'active'
+ AND agency_members.deleted_at IS NULL
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can update settings"
+ ON agency_settings FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = agency_settings.agency_id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.role IN ('owner', 'admin')
+ AND agency_members.status = 'active'
+ AND agency_members.deleted_at IS NULL
+ )
+ );
+
+CREATE POLICY "Agency owners and admins can insert settings"
+ ON agency_settings FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = agency_settings.agency_id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.role IN ('owner', 'admin')
+ AND agency_members.status = 'active'
+ AND agency_members.deleted_at IS NULL
+ )
+ );
+
+-- Create audit table for agency_settings
+CREATE TABLE IF NOT EXISTS agency_settings_audit (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL,
+ action audit_action NOT NULL,
+ actor_id UUID REFERENCES auth.users(id),
+ old_data JSONB,
+ new_data JSONB,
+ changed_fields JSONB,
+ action_timestamp TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Add indexes for audit table
+CREATE INDEX IF NOT EXISTS idx_agency_settings_audit_record_id ON agency_settings_audit(record_id);
+CREATE INDEX IF NOT EXISTS idx_agency_settings_audit_timestamp ON agency_settings_audit(action_timestamp);
+
+-- Enable RLS on audit table
+ALTER TABLE agency_settings_audit ENABLE ROW LEVEL SECURITY;
+
+-- RLS policy for audit table (Admin+ only)
+CREATE POLICY "Agency admins can read settings audit logs"
+ ON agency_settings_audit FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_settings s
+ JOIN agency_members am ON am.agency_id = s.agency_id
+ WHERE s.id = record_id
+ AND am.user_id = auth.uid()
+ AND am.role IN ('owner', 'admin')
+ AND am.status = 'active'
+ AND am.deleted_at IS NULL
+ )
+ );
+
+-- Add audit trigger for agency_settings
+DROP TRIGGER IF EXISTS audit_agency_settings_trigger ON agency_settings;
+CREATE TRIGGER audit_agency_settings_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON agency_settings
+ FOR EACH ROW EXECUTE FUNCTION create_audit_record();
+
+-- Add updated_at trigger for agency_settings
+DROP TRIGGER IF EXISTS update_agency_settings_updated_at ON agency_settings;
+CREATE TRIGGER update_agency_settings_updated_at
+ BEFORE UPDATE ON agency_settings
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Function to initialize default settings for new agencies
+CREATE OR REPLACE FUNCTION initialize_agency_settings()
+RETURNS TRIGGER AS $$
+BEGIN
+ INSERT INTO agency_settings (agency_id, created_by)
+ VALUES (NEW.id, NEW.created_by);
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Trigger to auto-create settings for new agencies
+DROP TRIGGER IF EXISTS initialize_agency_settings_trigger ON agencies;
+CREATE TRIGGER initialize_agency_settings_trigger
+ AFTER INSERT ON agencies
+ FOR EACH ROW EXECUTE FUNCTION initialize_agency_settings();
+
+-- Function to transfer agency ownership
+CREATE OR REPLACE FUNCTION transfer_agency_ownership(
+ p_agency_id UUID,
+ p_new_owner_id UUID
+)
+RETURNS VOID AS $$
+DECLARE
+ current_user_role agency_role;
+ target_user_exists BOOLEAN;
+BEGIN
+ -- Check if current user is owner
+ SELECT role INTO current_user_role
+ FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = auth.uid()
+ AND status = 'active'
+ AND deleted_at IS NULL;
+
+ IF current_user_role != 'owner' THEN
+ RAISE EXCEPTION 'Only agency owners can transfer ownership';
+ END IF;
+
+ -- Check if target user exists and is a member
+ SELECT EXISTS(
+ SELECT 1 FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = p_new_owner_id
+ AND status = 'active'
+ AND deleted_at IS NULL
+ ) INTO target_user_exists;
+
+ IF NOT target_user_exists THEN
+ RAISE EXCEPTION 'Target user must be an active agency member';
+ END IF;
+
+ -- Update current owner to admin
+ UPDATE agency_members
+ SET role = 'admin', updated_at = NOW(), updated_by = auth.uid()
+ WHERE agency_id = p_agency_id
+ AND user_id = auth.uid();
+
+ -- Update target user to owner
+ UPDATE agency_members
+ SET role = 'owner', updated_at = NOW(), updated_by = auth.uid()
+ WHERE agency_id = p_agency_id
+ AND user_id = p_new_owner_id;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to archive agency (soft delete)
+CREATE OR REPLACE FUNCTION archive_agency(p_agency_id UUID)
+RETURNS VOID AS $$
+DECLARE
+ current_user_role agency_role;
+BEGIN
+ -- Check if current user is owner
+ SELECT role INTO current_user_role
+ FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = auth.uid()
+ AND status = 'active'
+ AND deleted_at IS NULL;
+
+ IF current_user_role != 'owner' THEN
+ RAISE EXCEPTION 'Only agency owners can archive agencies';
+ END IF;
+
+ -- Soft delete agency
+ UPDATE agencies
+ SET deleted_at = NOW(), updated_at = NOW(), updated_by = auth.uid()
+ WHERE id = p_agency_id;
+
+ -- Soft delete all dockets
+ UPDATE dockets
+ SET deleted_at = NOW(), updated_at = NOW(), updated_by = auth.uid()
+ WHERE agency_id = p_agency_id AND deleted_at IS NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Create default settings for existing agencies
+INSERT INTO agency_settings (agency_id, created_by)
+SELECT id, created_by FROM agencies
+WHERE id NOT IN (SELECT agency_id FROM agency_settings)
+ON CONFLICT (agency_id) DO NOTHING;
+
+-- Generate public slugs for existing agencies if missing
+UPDATE agencies
+SET public_slug = lower(regexp_replace(name, '[^a-zA-Z0-9]+', '-', 'g'))
+WHERE public_slug IS NULL;
\ No newline at end of file
diff --git a/supabase/migrations/20250727173438_billowing_firefly.sql b/supabase/migrations/20250727173438_billowing_firefly.sql
new file mode 100644
index 0000000..3defc33
--- /dev/null
+++ b/supabase/migrations/20250727173438_billowing_firefly.sql
@@ -0,0 +1,272 @@
+/*
+ # Advanced Search System
+
+ 1. Search Infrastructure
+ - Full-text search vectors for dockets and comments
+ - Optimized indexes for faceted search
+ - Advanced search RPC function
+
+ 2. Search Indexes
+ - Text search vectors (tsvector)
+ - Facet indexes for filtering
+ - Performance optimization indexes
+
+ 3. Security
+ - RLS-aware search function
+ - Role-based result filtering
+ - Agency-scoped results only
+*/
+
+-- Add full-text search vectors to dockets
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'dockets' AND column_name = 'search_vector'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN search_vector tsvector;
+ END IF;
+END $$;
+
+-- Add full-text search vectors to comments
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'search_vector'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN search_vector tsvector;
+ END IF;
+END $$;
+
+-- Create indexes for full-text search
+CREATE INDEX IF NOT EXISTS idx_dockets_search_vector ON dockets USING gin(search_vector);
+CREATE INDEX IF NOT EXISTS idx_comments_search_vector ON comments USING gin(search_vector);
+
+-- Create indexes for faceted search
+CREATE INDEX IF NOT EXISTS idx_dockets_tags ON dockets USING gin(tags);
+CREATE INDEX IF NOT EXISTS idx_dockets_reference_code ON dockets(reference_code);
+CREATE INDEX IF NOT EXISTS idx_dockets_open_at ON dockets(open_at);
+CREATE INDEX IF NOT EXISTS idx_dockets_close_at ON dockets(close_at);
+
+CREATE INDEX IF NOT EXISTS idx_comments_commenter_email ON comments(commenter_email);
+CREATE INDEX IF NOT EXISTS idx_comments_commenter_name ON comments(commenter_name);
+CREATE INDEX IF NOT EXISTS idx_comments_commenter_organization ON comments(commenter_organization);
+
+CREATE INDEX IF NOT EXISTS idx_comment_attachments_mime_type ON comment_attachments(mime_type);
+CREATE INDEX IF NOT EXISTS idx_comment_attachments_file_size ON comment_attachments(file_size);
+
+CREATE INDEX IF NOT EXISTS idx_docket_attachments_mime_type ON docket_attachments(mime_type);
+CREATE INDEX IF NOT EXISTS idx_docket_attachments_file_size ON docket_attachments(file_size);
+
+-- Function to update search vectors for dockets
+CREATE OR REPLACE FUNCTION update_docket_search_vector()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
+ setweight(to_tsvector('english', COALESCE(NEW.summary, '')), 'C') ||
+ setweight(to_tsvector('english', COALESCE(NEW.reference_code, '')), 'D') ||
+ setweight(to_tsvector('english', COALESCE(array_to_string(NEW.tags, ' '), '')), 'D');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Function to update search vectors for comments
+CREATE OR REPLACE FUNCTION update_comment_search_vector()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(NEW.commenter_name, '')), 'B') ||
+ setweight(to_tsvector('english', COALESCE(NEW.commenter_organization, '')), 'C');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Create triggers to maintain search vectors
+DROP TRIGGER IF EXISTS trigger_update_docket_search_vector ON dockets;
+CREATE TRIGGER trigger_update_docket_search_vector
+ BEFORE INSERT OR UPDATE ON dockets
+ FOR EACH ROW EXECUTE FUNCTION update_docket_search_vector();
+
+DROP TRIGGER IF EXISTS trigger_update_comment_search_vector ON comments;
+CREATE TRIGGER trigger_update_comment_search_vector
+ BEFORE INSERT OR UPDATE ON comments
+ FOR EACH ROW EXECUTE FUNCTION update_comment_search_vector();
+
+-- Update existing records with search vectors
+UPDATE dockets SET search_vector =
+ setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(description, '')), 'B') ||
+ setweight(to_tsvector('english', COALESCE(summary, '')), 'C') ||
+ setweight(to_tsvector('english', COALESCE(reference_code, '')), 'D') ||
+ setweight(to_tsvector('english', COALESCE(array_to_string(tags, ' '), '')), 'D')
+WHERE search_vector IS NULL;
+
+UPDATE comments SET search_vector =
+ setweight(to_tsvector('english', COALESCE(content, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(commenter_name, '')), 'B') ||
+ setweight(to_tsvector('english', COALESCE(commenter_organization, '')), 'C')
+WHERE search_vector IS NULL;
+
+-- Advanced search function
+CREATE OR REPLACE FUNCTION advanced_search(
+ p_agency_id uuid,
+ p_user_role text,
+ p_query text DEFAULT '',
+ p_filters jsonb DEFAULT '{}'::jsonb,
+ p_limit integer DEFAULT 25,
+ p_offset integer DEFAULT 0
+)
+RETURNS TABLE (
+ result_type text,
+ result_id uuid,
+ title text,
+ content text,
+ status text,
+ created_at timestamptz,
+ updated_at timestamptz,
+ metadata jsonb,
+ rank real
+) AS $$
+DECLARE
+ search_query tsquery;
+ date_from timestamptz;
+ date_to timestamptz;
+ docket_statuses text[];
+ comment_statuses text[];
+ tags_filter text[];
+ has_attachments boolean;
+ mime_types text[];
+ min_file_size bigint;
+ max_file_size bigint;
+ commenter_email_filter text;
+ commenter_domain_filter text;
+ reference_code_filter text;
+ sort_by text;
+BEGIN
+ -- Parse search query
+ IF p_query IS NOT NULL AND p_query != '' THEN
+ search_query := plainto_tsquery('english', p_query);
+ END IF;
+
+ -- Parse filters
+ date_from := (p_filters->>'date_from')::timestamptz;
+ date_to := (p_filters->>'date_to')::timestamptz;
+ docket_statuses := ARRAY(SELECT jsonb_array_elements_text(p_filters->'docket_statuses'));
+ comment_statuses := ARRAY(SELECT jsonb_array_elements_text(p_filters->'comment_statuses'));
+ tags_filter := ARRAY(SELECT jsonb_array_elements_text(p_filters->'tags'));
+ has_attachments := (p_filters->>'has_attachments')::boolean;
+ mime_types := ARRAY(SELECT jsonb_array_elements_text(p_filters->'mime_types'));
+ min_file_size := (p_filters->>'min_file_size')::bigint;
+ max_file_size := (p_filters->>'max_file_size')::bigint;
+ commenter_email_filter := p_filters->>'commenter_email';
+ commenter_domain_filter := p_filters->>'commenter_domain';
+ reference_code_filter := p_filters->>'reference_code';
+ sort_by := COALESCE(p_filters->>'sort_by', 'relevance');
+
+ -- Search dockets
+ RETURN QUERY
+ SELECT
+ 'docket'::text as result_type,
+ d.id as result_id,
+ d.title,
+ d.description as content,
+ d.status,
+ d.created_at,
+ d.updated_at,
+ jsonb_build_object(
+ 'reference_code', d.reference_code,
+ 'tags', d.tags,
+ 'comment_deadline', d.comment_deadline,
+ 'comment_count', COALESCE(comment_counts.count, 0)
+ ) as metadata,
+ CASE
+ WHEN search_query IS NOT NULL THEN ts_rank(d.search_vector, search_query)
+ ELSE 0
+ END as rank
+ FROM dockets d
+ LEFT JOIN (
+ SELECT docket_id, COUNT(*) as count
+ FROM comments
+ WHERE status = 'published'
+ GROUP BY docket_id
+ ) comment_counts ON d.id = comment_counts.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND (search_query IS NULL OR d.search_vector @@ search_query)
+ AND (date_from IS NULL OR d.created_at >= date_from)
+ AND (date_to IS NULL OR d.created_at <= date_to)
+ AND (docket_statuses IS NULL OR d.status = ANY(docket_statuses))
+ AND (tags_filter IS NULL OR d.tags && tags_filter)
+ AND (reference_code_filter IS NULL OR d.reference_code ILIKE '%' || reference_code_filter || '%')
+
+ UNION ALL
+
+ -- Search comments (with role-based filtering)
+ SELECT
+ 'comment'::text as result_type,
+ c.id as result_id,
+ d.title,
+ c.content,
+ c.status,
+ c.created_at,
+ c.updated_at,
+ jsonb_build_object(
+ 'docket_id', c.docket_id,
+ 'docket_title', d.title,
+ 'commenter_name', c.commenter_name,
+ 'commenter_email', c.commenter_email,
+ 'commenter_organization', c.commenter_organization,
+ 'attachment_count', COALESCE(attachment_counts.count, 0)
+ ) as metadata,
+ CASE
+ WHEN search_query IS NOT NULL THEN ts_rank(c.search_vector, search_query)
+ ELSE 0
+ END as rank
+ FROM comments c
+ JOIN dockets d ON c.docket_id = d.id
+ LEFT JOIN (
+ SELECT comment_id, COUNT(*) as count
+ FROM comment_attachments
+ GROUP BY comment_id
+ ) attachment_counts ON c.id = attachment_counts.comment_id
+ WHERE d.agency_id = p_agency_id
+ AND (search_query IS NULL OR c.search_vector @@ search_query)
+ AND (date_from IS NULL OR c.created_at >= date_from)
+ AND (date_to IS NULL OR c.created_at <= date_to)
+ AND (
+ -- Role-based comment visibility
+ CASE p_user_role
+ WHEN 'viewer' THEN c.status = 'published'
+ WHEN 'reviewer' THEN c.status IN ('published', 'submitted', 'under_review')
+ ELSE TRUE -- manager, admin, owner see all
+ END
+ )
+ AND (comment_statuses IS NULL OR c.status = ANY(comment_statuses))
+ AND (commenter_email_filter IS NULL OR c.commenter_email ILIKE '%' || commenter_email_filter || '%')
+ AND (commenter_domain_filter IS NULL OR c.commenter_email ILIKE '%@' || commenter_domain_filter || '%')
+ AND (
+ has_attachments IS NULL OR
+ (has_attachments = true AND attachment_counts.count > 0) OR
+ (has_attachments = false AND attachment_counts.count = 0)
+ )
+
+ ORDER BY
+ CASE sort_by
+ WHEN 'relevance' THEN rank
+ WHEN 'newest' THEN EXTRACT(EPOCH FROM created_at)
+ WHEN 'oldest' THEN -EXTRACT(EPOCH FROM created_at)
+ WHEN 'alphabetical' THEN 0
+ END DESC,
+ CASE WHEN sort_by = 'alphabetical' THEN title END ASC,
+ created_at DESC
+
+ LIMIT p_limit
+ OFFSET p_offset;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Grant execute permission to authenticated users
+GRANT EXECUTE ON FUNCTION advanced_search TO authenticated;
\ No newline at end of file
diff --git a/supabase/migrations/20250727173859_black_recipe.sql b/supabase/migrations/20250727173859_black_recipe.sql
new file mode 100644
index 0000000..17eb7f6
--- /dev/null
+++ b/supabase/migrations/20250727173859_black_recipe.sql
@@ -0,0 +1,307 @@
+/*
+ # Reports & Exports System
+
+ 1. New Tables
+ - `exports`
+ - `id` (uuid, primary key)
+ - `agency_id` (uuid, foreign key)
+ - `docket_id` (uuid, nullable - for docket-specific exports)
+ - `export_type` (enum: csv, zip, combined)
+ - `filters_json` (jsonb - export parameters)
+ - `file_url` (text - signed URL to download)
+ - `file_path` (text - storage path)
+ - `size_bytes` (bigint)
+ - `status` (enum: pending, processing, completed, failed, expired)
+ - `progress_percent` (integer, 0-100)
+ - `error_message` (text, nullable)
+ - `expires_at` (timestamptz - 24h from completion)
+ - `created_by` (uuid, foreign key to profiles)
+ - `created_at` (timestamptz)
+ - `updated_at` (timestamptz)
+
+ 2. Storage Buckets
+ - `agency-exports` bucket for temporary export files
+
+ 3. Security
+ - Enable RLS on `exports` table
+ - Add policies for agency members to manage their exports
+ - Add cleanup function for expired exports
+
+ 4. Functions
+ - Export generation functions
+ - Analytics aggregation functions
+*/
+
+-- Create export type enum
+CREATE TYPE export_type AS ENUM ('csv', 'zip', 'combined');
+
+-- Create export status enum
+CREATE TYPE export_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'expired');
+
+-- Create exports table
+CREATE TABLE IF NOT EXISTS exports (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ agency_id uuid NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
+ docket_id uuid REFERENCES dockets(id) ON DELETE CASCADE,
+ export_type export_type NOT NULL,
+ filters_json jsonb DEFAULT '{}',
+ file_url text,
+ file_path text,
+ size_bytes bigint DEFAULT 0,
+ status export_status DEFAULT 'pending',
+ progress_percent integer DEFAULT 0 CHECK (progress_percent >= 0 AND progress_percent <= 100),
+ error_message text,
+ expires_at timestamptz,
+ created_by uuid REFERENCES profiles(id),
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Add indexes for performance
+CREATE INDEX IF NOT EXISTS idx_exports_agency_id ON exports(agency_id);
+CREATE INDEX IF NOT EXISTS idx_exports_docket_id ON exports(docket_id);
+CREATE INDEX IF NOT EXISTS idx_exports_status ON exports(status);
+CREATE INDEX IF NOT EXISTS idx_exports_created_by ON exports(created_by);
+CREATE INDEX IF NOT EXISTS idx_exports_expires_at ON exports(expires_at);
+CREATE INDEX IF NOT EXISTS idx_exports_created_at ON exports(created_at);
+
+-- Enable RLS
+ALTER TABLE exports ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies
+CREATE POLICY "Agency members can view exports for their agency"
+ ON exports
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = exports.agency_id
+ AND agency_members.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Reviewers+ can create exports"
+ ON exports
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_members.agency_id = exports.agency_id
+ AND agency_members.user_id = auth.uid()
+ AND agency_members.role IN ('owner', 'admin', 'manager', 'reviewer')
+ )
+ AND auth.uid() = created_by
+ );
+
+CREATE POLICY "Users can update their own exports"
+ ON exports
+ FOR UPDATE
+ TO authenticated
+ USING (auth.uid() = created_by);
+
+CREATE POLICY "Users can delete their own exports"
+ ON exports
+ FOR DELETE
+ TO authenticated
+ USING (auth.uid() = created_by);
+
+-- Updated at trigger
+CREATE TRIGGER update_exports_updated_at
+ BEFORE UPDATE ON exports
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Function to get agency analytics
+CREATE OR REPLACE FUNCTION get_agency_analytics(p_agency_id uuid, p_date_from timestamptz DEFAULT NULL, p_date_to timestamptz DEFAULT NULL)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ result jsonb;
+ date_filter_start timestamptz;
+ date_filter_end timestamptz;
+BEGIN
+ -- Set default date range (last 90 days if not specified)
+ date_filter_start := COALESCE(p_date_from, now() - interval '90 days');
+ date_filter_end := COALESCE(p_date_to, now());
+
+ -- Check if user has access to this agency
+ IF NOT EXISTS (
+ SELECT 1 FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = auth.uid()
+ ) THEN
+ RAISE EXCEPTION 'Access denied to agency analytics';
+ END IF;
+
+ -- Build analytics object
+ SELECT jsonb_build_object(
+ 'total_dockets', (
+ SELECT COUNT(*) FROM dockets
+ WHERE agency_id = p_agency_id
+ AND created_at BETWEEN date_filter_start AND date_filter_end
+ ),
+ 'active_dockets', (
+ SELECT COUNT(*) FROM dockets
+ WHERE agency_id = p_agency_id
+ AND status = 'open'
+ ),
+ 'total_comments', (
+ SELECT COUNT(*) FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND c.created_at BETWEEN date_filter_start AND date_filter_end
+ ),
+ 'approved_comments', (
+ SELECT COUNT(*) FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND c.status = 'published'
+ AND c.created_at BETWEEN date_filter_start AND date_filter_end
+ ),
+ 'pending_comments', (
+ SELECT COUNT(*) FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND c.status IN ('submitted', 'under_review')
+ ),
+ 'total_attachments', (
+ SELECT COUNT(*) FROM comment_attachments ca
+ JOIN comments c ON c.id = ca.comment_id
+ JOIN dockets d ON d.id = c.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND ca.created_at BETWEEN date_filter_start AND date_filter_end
+ ),
+ 'total_attachment_size_mb', (
+ SELECT COALESCE(ROUND(SUM(ca.file_size) / 1024.0 / 1024.0, 2), 0) FROM comment_attachments ca
+ JOIN comments c ON c.id = ca.comment_id
+ JOIN dockets d ON d.id = c.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND ca.created_at BETWEEN date_filter_start AND date_filter_end
+ ),
+ 'avg_comments_per_docket', (
+ SELECT COALESCE(ROUND(AVG(comment_count), 1), 0) FROM (
+ SELECT COUNT(c.id) as comment_count
+ FROM dockets d
+ LEFT JOIN comments c ON c.docket_id = d.id
+ WHERE d.agency_id = p_agency_id
+ AND d.created_at BETWEEN date_filter_start AND date_filter_end
+ GROUP BY d.id
+ ) subq
+ ),
+ 'unique_commenters', (
+ SELECT COUNT(DISTINCT c.commenter_email) FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ WHERE d.agency_id = p_agency_id
+ AND c.commenter_email IS NOT NULL
+ AND c.created_at BETWEEN date_filter_start AND date_filter_end
+ ),
+ 'date_range', jsonb_build_object(
+ 'from', date_filter_start,
+ 'to', date_filter_end
+ )
+ ) INTO result;
+
+ RETURN result;
+END;
+$$;
+
+-- Function to create export job
+CREATE OR REPLACE FUNCTION create_export_job(
+ p_agency_id uuid,
+ p_docket_id uuid DEFAULT NULL,
+ p_export_type export_type,
+ p_filters jsonb DEFAULT '{}'
+)
+RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ export_id uuid;
+ user_role agency_role;
+BEGIN
+ -- Check if user has access to this agency and required permissions
+ SELECT role INTO user_role
+ FROM agency_members
+ WHERE agency_id = p_agency_id
+ AND user_id = auth.uid();
+
+ IF user_role IS NULL THEN
+ RAISE EXCEPTION 'Access denied to agency';
+ END IF;
+
+ IF user_role = 'viewer' THEN
+ RAISE EXCEPTION 'Insufficient permissions to create exports';
+ END IF;
+
+ -- Create export record
+ INSERT INTO exports (
+ agency_id,
+ docket_id,
+ export_type,
+ filters_json,
+ created_by,
+ expires_at
+ ) VALUES (
+ p_agency_id,
+ p_docket_id,
+ p_export_type,
+ p_filters,
+ auth.uid(),
+ now() + interval '24 hours'
+ ) RETURNING id INTO export_id;
+
+ RETURN export_id;
+END;
+$$;
+
+-- Function to update export progress
+CREATE OR REPLACE FUNCTION update_export_progress(
+ p_export_id uuid,
+ p_status export_status,
+ p_progress_percent integer DEFAULT NULL,
+ p_file_path text DEFAULT NULL,
+ p_file_url text DEFAULT NULL,
+ p_size_bytes bigint DEFAULT NULL,
+ p_error_message text DEFAULT NULL
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ UPDATE exports SET
+ status = p_status,
+ progress_percent = COALESCE(p_progress_percent, progress_percent),
+ file_path = COALESCE(p_file_path, file_path),
+ file_url = COALESCE(p_file_url, file_url),
+ size_bytes = COALESCE(p_size_bytes, size_bytes),
+ error_message = p_error_message,
+ updated_at = now()
+ WHERE id = p_export_id;
+END;
+$$;
+
+-- Function to cleanup expired exports
+CREATE OR REPLACE FUNCTION cleanup_expired_exports()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ -- Mark exports as expired
+ UPDATE exports
+ SET status = 'expired', updated_at = now()
+ WHERE expires_at < now()
+ AND status = 'completed';
+
+ -- Delete old expired exports (older than 7 days)
+ DELETE FROM exports
+ WHERE status = 'expired'
+ AND updated_at < now() - interval '7 days';
+END;
+$$;
\ No newline at end of file
diff --git a/supabase/migrations/20250727180021_tight_sun.sql b/supabase/migrations/20250727180021_tight_sun.sql
new file mode 100644
index 0000000..a9158dd
--- /dev/null
+++ b/supabase/migrations/20250727180021_tight_sun.sql
@@ -0,0 +1,115 @@
+/*
+ # Contact Support System
+
+ 1. New Tables
+ - `contact_submissions`
+ - `id` (uuid, primary key)
+ - `user_id` (uuid, nullable - for logged in users)
+ - `name` (text, required)
+ - `email` (text, required)
+ - `organization` (text, optional)
+ - `subject` (text, required)
+ - `category` (text, required)
+ - `message` (text, required)
+ - `priority` (text, default 'normal')
+ - `status` (text, default 'open')
+ - `created_at` (timestamp)
+ - `updated_at` (timestamp)
+
+ 2. Security
+ - Enable RLS on `contact_submissions` table
+ - Add policy for users to read their own submissions
+ - Add policy for support staff to read all submissions
+
+ 3. Indexes
+ - Index on email for support staff lookup
+ - Index on category for filtering
+ - Index on status for queue management
+*/
+
+-- Create contact submission categories enum
+CREATE TYPE contact_category AS ENUM (
+ 'technical_support',
+ 'account_access',
+ 'agency_setup',
+ 'feature_request',
+ 'bug_report',
+ 'general_inquiry',
+ 'training_request',
+ 'billing_question'
+);
+
+-- Create contact submission status enum
+CREATE TYPE contact_status AS ENUM (
+ 'open',
+ 'in_progress',
+ 'resolved',
+ 'closed'
+);
+
+-- Create contact submission priority enum
+CREATE TYPE contact_priority AS ENUM (
+ 'low',
+ 'normal',
+ 'high',
+ 'urgent'
+);
+
+-- Create contact submissions table
+CREATE TABLE IF NOT EXISTS contact_submissions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid REFERENCES profiles(id) ON DELETE SET NULL,
+ name text NOT NULL,
+ email text NOT NULL,
+ organization text,
+ subject text NOT NULL,
+ category contact_category NOT NULL,
+ message text NOT NULL,
+ priority contact_priority DEFAULT 'normal',
+ status contact_status DEFAULT 'open',
+ user_agent text,
+ ip_address inet,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Enable RLS
+ALTER TABLE contact_submissions ENABLE ROW LEVEL SECURITY;
+
+-- Create policies
+CREATE POLICY "Users can read own submissions"
+ ON contact_submissions
+ FOR SELECT
+ TO authenticated
+ USING (auth.uid() = user_id);
+
+CREATE POLICY "Users can create submissions"
+ ON contact_submissions
+ FOR INSERT
+ TO authenticated, anon
+ WITH CHECK (true);
+
+CREATE POLICY "Support staff can read all submissions"
+ ON contact_submissions
+ FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM profiles
+ WHERE id = auth.uid()
+ AND role = 'agency'
+ AND email LIKE '%@opencomments.us'
+ )
+ );
+
+-- Create indexes
+CREATE INDEX idx_contact_submissions_email ON contact_submissions(email);
+CREATE INDEX idx_contact_submissions_category ON contact_submissions(category);
+CREATE INDEX idx_contact_submissions_status ON contact_submissions(status);
+CREATE INDEX idx_contact_submissions_created_at ON contact_submissions(created_at);
+CREATE INDEX idx_contact_submissions_user_id ON contact_submissions(user_id);
+
+-- Create updated_at trigger
+CREATE TRIGGER update_contact_submissions_updated_at
+ BEFORE UPDATE ON contact_submissions
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
\ No newline at end of file
diff --git a/supabase/migrations/20250727180253_billowing_flower.sql b/supabase/migrations/20250727180253_billowing_flower.sql
new file mode 100644
index 0000000..1009c81
--- /dev/null
+++ b/supabase/migrations/20250727180253_billowing_flower.sql
@@ -0,0 +1,229 @@
+/*
+ # Public Comment System
+
+ 1. New Tables
+ - `public_comment_submissions` - Stores public comments from citizens
+ - `public_comment_attachments` - File attachments for public comments
+
+ 2. RLS Policies
+ - Allow public to insert comments on open dockets
+ - Allow public to read approved comments only
+ - Prevent access to pending/rejected comments
+
+ 3. Functions
+ - `submit_public_comment` - RPC for comment submission
+ - `get_public_dockets` - Get open dockets for public browsing
+*/
+
+-- Create public comment submissions table
+CREATE TABLE IF NOT EXISTS public_comment_submissions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ docket_id uuid REFERENCES dockets(id) ON DELETE CASCADE,
+ commenter_name text,
+ commenter_email text,
+ commenter_organization text,
+ content text NOT NULL,
+ status comment_status DEFAULT 'pending',
+ tracking_id text UNIQUE DEFAULT encode(gen_random_bytes(8), 'hex'),
+ ip_address inet,
+ user_agent text,
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Create public comment attachments table
+CREATE TABLE IF NOT EXISTS public_comment_attachments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid REFERENCES public_comment_submissions(id) ON DELETE CASCADE,
+ filename text NOT NULL,
+ file_url text NOT NULL,
+ file_path text NOT NULL,
+ mime_type text NOT NULL,
+ file_size bigint NOT NULL,
+ created_at timestamptz DEFAULT now()
+);
+
+-- Enable RLS
+ALTER TABLE public_comment_submissions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public_comment_attachments ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies for public comment submissions
+CREATE POLICY "Anyone can insert comments on open dockets"
+ ON public_comment_submissions
+ FOR INSERT
+ TO anon, authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM dockets
+ WHERE id = docket_id
+ AND status = 'open'
+ AND (close_at IS NULL OR close_at > now())
+ )
+ );
+
+CREATE POLICY "Public can read approved comments"
+ ON public_comment_submissions
+ FOR SELECT
+ TO anon, authenticated
+ USING (status = 'approved');
+
+CREATE POLICY "Agency members can read all comments for their dockets"
+ ON public_comment_submissions
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = docket_id AND am.user_id = auth.uid()
+ )
+ );
+
+-- RLS Policies for public comment attachments
+CREATE POLICY "Public can read attachments for approved comments"
+ ON public_comment_attachments
+ FOR SELECT
+ TO anon, authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public_comment_submissions
+ WHERE id = comment_id AND status = 'approved'
+ )
+ );
+
+CREATE POLICY "Agency members can read all attachments for their dockets"
+ ON public_comment_attachments
+ FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public_comment_submissions pcs
+ JOIN dockets d ON d.id = pcs.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE pcs.id = comment_id AND am.user_id = auth.uid()
+ )
+ );
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_public_comments_docket_id ON public_comment_submissions(docket_id);
+CREATE INDEX IF NOT EXISTS idx_public_comments_status ON public_comment_submissions(status);
+CREATE INDEX IF NOT EXISTS idx_public_comments_tracking_id ON public_comment_submissions(tracking_id);
+CREATE INDEX IF NOT EXISTS idx_public_attachments_comment_id ON public_comment_attachments(comment_id);
+
+-- Function to submit public comment
+CREATE OR REPLACE FUNCTION submit_public_comment(
+ p_docket_slug text,
+ p_commenter_name text DEFAULT NULL,
+ p_commenter_email text DEFAULT NULL,
+ p_commenter_organization text DEFAULT NULL,
+ p_content text,
+ p_ip_address inet DEFAULT NULL,
+ p_user_agent text DEFAULT NULL
+) RETURNS json AS $$
+DECLARE
+ v_docket_id uuid;
+ v_comment_id uuid;
+ v_tracking_id text;
+ v_auto_publish boolean;
+BEGIN
+ -- Get docket info
+ SELECT id, auto_publish INTO v_docket_id, v_auto_publish
+ FROM dockets
+ WHERE slug = p_docket_slug
+ AND status = 'open'
+ AND (close_at IS NULL OR close_at > now());
+
+ IF v_docket_id IS NULL THEN
+ RAISE EXCEPTION 'Docket not found or comment period closed';
+ END IF;
+
+ -- Insert comment
+ INSERT INTO public_comment_submissions (
+ docket_id,
+ commenter_name,
+ commenter_email,
+ commenter_organization,
+ content,
+ status,
+ ip_address,
+ user_agent
+ ) VALUES (
+ v_docket_id,
+ p_commenter_name,
+ p_commenter_email,
+ p_commenter_organization,
+ p_content,
+ CASE WHEN v_auto_publish THEN 'approved'::comment_status ELSE 'pending'::comment_status END,
+ p_ip_address,
+ p_user_agent
+ ) RETURNING id, tracking_id INTO v_comment_id, v_tracking_id;
+
+ RETURN json_build_object(
+ 'comment_id', v_comment_id,
+ 'tracking_id', v_tracking_id,
+ 'status', CASE WHEN v_auto_publish THEN 'approved' ELSE 'pending' END
+ );
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to get public dockets
+CREATE OR REPLACE FUNCTION get_public_dockets(
+ p_search_query text DEFAULT NULL,
+ p_tags text[] DEFAULT NULL,
+ p_status text DEFAULT 'open',
+ p_limit integer DEFAULT 20,
+ p_offset integer DEFAULT 0
+) RETURNS TABLE (
+ id uuid,
+ title text,
+ summary text,
+ slug text,
+ tags text[],
+ status text,
+ open_at timestamptz,
+ close_at timestamptz,
+ comment_count bigint,
+ agency_name text
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ d.id,
+ d.title,
+ d.summary,
+ d.slug,
+ d.tags,
+ d.status,
+ d.open_at,
+ d.close_at,
+ COUNT(pcs.id) as comment_count,
+ a.name as agency_name
+ FROM dockets d
+ JOIN agencies a ON a.id = d.agency_id
+ LEFT JOIN public_comment_submissions pcs ON pcs.docket_id = d.id AND pcs.status = 'approved'
+ WHERE
+ (p_status IS NULL OR d.status = p_status)
+ AND (p_search_query IS NULL OR d.search_vector @@ plainto_tsquery('english', p_search_query))
+ AND (p_tags IS NULL OR d.tags && p_tags)
+ AND d.status != 'archived'
+ GROUP BY d.id, a.name
+ ORDER BY
+ CASE WHEN p_search_query IS NOT NULL THEN ts_rank(d.search_vector, plainto_tsquery('english', p_search_query)) END DESC,
+ d.created_at DESC
+ LIMIT p_limit
+ OFFSET p_offset;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Update existing comments table to work with new public system
+DO $$
+BEGIN
+ -- Add tracking_id to existing comments table if it doesn't exist
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'tracking_id'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN tracking_id text UNIQUE DEFAULT encode(gen_random_bytes(8), 'hex');
+ CREATE INDEX IF NOT EXISTS idx_comments_tracking_id ON comments(tracking_id);
+ END IF;
+END $$;
\ No newline at end of file
diff --git a/supabase/migrations/20250727181036_warm_cake.sql b/supabase/migrations/20250727181036_warm_cake.sql
new file mode 100644
index 0000000..1159e8d
--- /dev/null
+++ b/supabase/migrations/20250727181036_warm_cake.sql
@@ -0,0 +1,382 @@
+/*
+ # Authenticated Comment Submission & Integrity Controls
+
+ 1. New Tables
+ - `commenter_info`: Stores representation and certification data
+ - `comment_rate_limits`: Tracks submission rates for anti-spam
+
+ 2. Extended Tables
+ - `comments`: Added OAuth provider, metadata, and audit columns
+ - `dockets`: Added submission rules and limits
+
+ 3. Security
+ - RLS policies for authenticated comment submission
+ - Rate limiting and duplicate detection
+ - Metadata capture triggers
+
+ 4. Functions
+ - `can_submit_comment`: Rate limiting and validation
+ - `submit_authenticated_comment`: Complete submission workflow
+*/
+
+-- Add OAuth and metadata columns to comments table
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'oauth_provider'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN oauth_provider text;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'oauth_uid'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN oauth_uid text;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'geo_country'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN geo_country text;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'content_hash'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN content_hash text;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'comments' AND column_name = 'captcha_token'
+ ) THEN
+ ALTER TABLE comments ADD COLUMN captcha_token text;
+ END IF;
+END $$;
+
+-- Add submission rules to dockets table
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'dockets' AND column_name = 'max_comment_length'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN max_comment_length integer DEFAULT 4000;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'dockets' AND column_name = 'max_comments_per_user'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN max_comments_per_user integer DEFAULT 3;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'dockets' AND column_name = 'uploads_enabled'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN uploads_enabled boolean DEFAULT true;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'dockets' AND column_name = 'max_files_per_comment'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN max_files_per_comment integer DEFAULT 3;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'dockets' AND column_name = 'allowed_mime_types'
+ ) THEN
+ ALTER TABLE dockets ADD COLUMN allowed_mime_types text[] DEFAULT ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/jpeg', 'image/png'];
+ END IF;
+END $$;
+
+-- Create commenter_info table
+CREATE TABLE IF NOT EXISTS commenter_info (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id uuid REFERENCES comments(id) ON DELETE CASCADE,
+ representation text NOT NULL CHECK (representation IN ('myself', 'organization', 'behalf_of_another')),
+ organization_name text,
+ authorization_statement text,
+ perjury_certified boolean NOT NULL DEFAULT false,
+ certification_timestamp timestamptz DEFAULT now(),
+ created_at timestamptz DEFAULT now()
+);
+
+-- Create comment_rate_limits table for anti-spam
+CREATE TABLE IF NOT EXISTS comment_rate_limits (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid,
+ ip_address inet,
+ docket_id uuid REFERENCES dockets(id),
+ submission_count integer DEFAULT 1,
+ last_submission timestamptz DEFAULT now(),
+ created_at timestamptz DEFAULT now(),
+ updated_at timestamptz DEFAULT now()
+);
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_commenter_info_comment_id ON commenter_info(comment_id);
+CREATE INDEX IF NOT EXISTS idx_comment_rate_limits_user_id ON comment_rate_limits(user_id);
+CREATE INDEX IF NOT EXISTS idx_comment_rate_limits_ip_address ON comment_rate_limits(ip_address);
+CREATE INDEX IF NOT EXISTS idx_comment_rate_limits_docket_id ON comment_rate_limits(docket_id);
+CREATE INDEX IF NOT EXISTS idx_comments_oauth_provider ON comments(oauth_provider);
+CREATE INDEX IF NOT EXISTS idx_comments_oauth_uid ON comments(oauth_uid);
+CREATE INDEX IF NOT EXISTS idx_comments_content_hash ON comments(content_hash);
+
+-- Function to check if user can submit comment
+CREATE OR REPLACE FUNCTION can_submit_comment(
+ p_user_id uuid,
+ p_docket_id uuid,
+ p_ip_address inet,
+ p_content_hash text
+) RETURNS jsonb AS $$
+DECLARE
+ v_docket record;
+ v_user_comment_count integer;
+ v_ip_rate_limit integer;
+ v_duplicate_count integer;
+BEGIN
+ -- Get docket submission rules
+ SELECT max_comments_per_user INTO v_docket
+ FROM dockets
+ WHERE id = p_docket_id;
+
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object('allowed', false, 'reason', 'Docket not found');
+ END IF;
+
+ -- Check user comment limit per docket
+ SELECT COUNT(*) INTO v_user_comment_count
+ FROM comments
+ WHERE user_id = p_user_id AND docket_id = p_docket_id;
+
+ IF v_user_comment_count >= COALESCE(v_docket.max_comments_per_user, 3) THEN
+ RETURN jsonb_build_object('allowed', false, 'reason', 'Maximum comments per user exceeded');
+ END IF;
+
+ -- Check IP rate limit (10 comments per hour)
+ SELECT COUNT(*) INTO v_ip_rate_limit
+ FROM comments
+ WHERE ip_address = p_ip_address
+ AND created_at > now() - interval '1 hour';
+
+ IF v_ip_rate_limit >= 10 THEN
+ RETURN jsonb_build_object('allowed', false, 'reason', 'Rate limit exceeded');
+ END IF;
+
+ -- Check for duplicate content (last 10 minutes)
+ SELECT COUNT(*) INTO v_duplicate_count
+ FROM comments
+ WHERE content_hash = p_content_hash
+ AND created_at > now() - interval '10 minutes';
+
+ IF v_duplicate_count > 0 THEN
+ RETURN jsonb_build_object('allowed', false, 'reason', 'Duplicate comment detected');
+ END IF;
+
+ RETURN jsonb_build_object('allowed', true, 'reason', 'OK');
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to submit authenticated comment
+CREATE OR REPLACE FUNCTION submit_authenticated_comment(
+ p_docket_slug text,
+ p_content text,
+ p_commenter_name text DEFAULT NULL,
+ p_commenter_email text DEFAULT NULL,
+ p_commenter_organization text DEFAULT NULL,
+ p_representation text DEFAULT 'myself',
+ p_organization_name text DEFAULT NULL,
+ p_authorization_statement text DEFAULT NULL,
+ p_perjury_certified boolean DEFAULT false,
+ p_captcha_token text DEFAULT NULL,
+ p_oauth_provider text DEFAULT NULL,
+ p_oauth_uid text DEFAULT NULL,
+ p_ip_address inet DEFAULT NULL,
+ p_user_agent text DEFAULT NULL
+) RETURNS jsonb AS $$
+DECLARE
+ v_docket record;
+ v_comment_id uuid;
+ v_tracking_id text;
+ v_content_hash text;
+ v_can_submit jsonb;
+ v_user_id uuid;
+BEGIN
+ -- Get current user
+ v_user_id := auth.uid();
+
+ IF v_user_id IS NULL THEN
+ RETURN jsonb_build_object('success', false, 'error', 'Authentication required');
+ END IF;
+
+ -- Validate perjury certification
+ IF NOT p_perjury_certified THEN
+ RETURN jsonb_build_object('success', false, 'error', 'Perjury certification required');
+ END IF;
+
+ -- Get docket information
+ SELECT d.*, a.name as agency_name
+ INTO v_docket
+ FROM dockets d
+ JOIN agencies a ON a.id = d.agency_id
+ WHERE d.slug = p_docket_slug
+ AND d.status = 'open'
+ AND (d.close_at IS NULL OR d.close_at > now());
+
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object('success', false, 'error', 'Docket not found or closed');
+ END IF;
+
+ -- Validate content length
+ IF length(p_content) > COALESCE(v_docket.max_comment_length, 4000) THEN
+ RETURN jsonb_build_object('success', false, 'error', 'Comment exceeds maximum length');
+ END IF;
+
+ -- Generate content hash for duplicate detection
+ v_content_hash := encode(digest(p_content, 'sha256'), 'hex');
+
+ -- Check if user can submit
+ v_can_submit := can_submit_comment(v_user_id, v_docket.id, p_ip_address, v_content_hash);
+
+ IF NOT (v_can_submit->>'allowed')::boolean THEN
+ RETURN jsonb_build_object('success', false, 'error', v_can_submit->>'reason');
+ END IF;
+
+ -- Generate tracking ID
+ v_tracking_id := upper(substring(encode(gen_random_bytes(8), 'base64'), 1, 12));
+
+ -- Insert comment
+ INSERT INTO comments (
+ docket_id,
+ user_id,
+ content,
+ status,
+ commenter_name,
+ commenter_email,
+ commenter_organization,
+ oauth_provider,
+ oauth_uid,
+ ip_address,
+ user_agent,
+ content_hash,
+ captcha_token,
+ created_at
+ ) VALUES (
+ v_docket.id,
+ v_user_id,
+ p_content,
+ CASE WHEN v_docket.auto_publish THEN 'published' ELSE 'submitted' END,
+ p_commenter_name,
+ p_commenter_email,
+ p_commenter_organization,
+ p_oauth_provider,
+ p_oauth_uid,
+ p_ip_address,
+ p_user_agent,
+ v_content_hash,
+ p_captcha_token,
+ now()
+ ) RETURNING id INTO v_comment_id;
+
+ -- Insert commenter info
+ INSERT INTO commenter_info (
+ comment_id,
+ representation,
+ organization_name,
+ authorization_statement,
+ perjury_certified,
+ certification_timestamp
+ ) VALUES (
+ v_comment_id,
+ p_representation,
+ p_organization_name,
+ p_authorization_statement,
+ p_perjury_certified,
+ now()
+ );
+
+ -- Update rate limiting
+ INSERT INTO comment_rate_limits (user_id, ip_address, docket_id, submission_count, last_submission)
+ VALUES (v_user_id, p_ip_address, v_docket.id, 1, now())
+ ON CONFLICT (user_id, docket_id)
+ DO UPDATE SET
+ submission_count = comment_rate_limits.submission_count + 1,
+ last_submission = now(),
+ updated_at = now();
+
+ RETURN jsonb_build_object(
+ 'success', true,
+ 'comment_id', v_comment_id,
+ 'tracking_id', v_tracking_id,
+ 'docket_title', v_docket.title,
+ 'agency_name', v_docket.agency_name,
+ 'status', CASE WHEN v_docket.auto_publish THEN 'published' ELSE 'submitted' END
+ );
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Enable RLS on new tables
+ALTER TABLE commenter_info ENABLE ROW LEVEL SECURITY;
+ALTER TABLE comment_rate_limits ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies for commenter_info
+CREATE POLICY "Users can read own commenter info" ON commenter_info
+FOR SELECT TO authenticated
+USING (EXISTS (
+ SELECT 1 FROM comments
+ WHERE comments.id = commenter_info.comment_id
+ AND comments.user_id = auth.uid()
+));
+
+CREATE POLICY "Agency members can read commenter info" ON commenter_info
+FOR SELECT TO authenticated
+USING (EXISTS (
+ SELECT 1 FROM comments c
+ JOIN dockets d ON d.id = c.docket_id
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE c.id = commenter_info.comment_id
+ AND am.user_id = auth.uid()
+));
+
+-- RLS policies for comment_rate_limits
+CREATE POLICY "Users can read own rate limits" ON comment_rate_limits
+FOR SELECT TO authenticated
+USING (user_id = auth.uid());
+
+CREATE POLICY "Agency members can read rate limits for their dockets" ON comment_rate_limits
+FOR SELECT TO authenticated
+USING (EXISTS (
+ SELECT 1 FROM dockets d
+ JOIN agency_members am ON am.agency_id = d.agency_id
+ WHERE d.id = comment_rate_limits.docket_id
+ AND am.user_id = auth.uid()
+));
+
+-- Update comments RLS for authenticated submission
+CREATE POLICY "Authenticated users can insert comments" ON comments
+FOR INSERT TO authenticated
+WITH CHECK (auth.uid() = user_id);
+
+-- Trigger to populate geo_country (placeholder for now)
+CREATE OR REPLACE FUNCTION populate_comment_metadata()
+RETURNS trigger AS $$
+BEGIN
+ -- Placeholder for geo-location lookup
+ -- In production, this would use a GeoIP service
+ NEW.geo_country := 'US';
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_populate_comment_metadata
+BEFORE INSERT ON comments
+FOR EACH ROW EXECUTE FUNCTION populate_comment_metadata();
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 147380a..753ff13 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,8 +3,9 @@ import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
+ root: '.',
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
- },
-});
+ }
+});
\ No newline at end of file