v17.4: Dashboard & Partner Portal Completion
Priority: HIGH Estimated Effort: 2-3 weeks (single developer) Dependencies: v17.0 (RLS policies MUST be deployed first) Impact: Enables partners to self-manage services Phase 1 Status: ✅ Complete - Ready for Testing (2026-01-22) Phase 2 Status: ✅ Complete - Ready for Testing (2026-01-23) Phase 3 Status: ✅ Complete - Ready for Testing (2026-01-24) Phase 4 Status: ✅ Complete - Ready for Testing (2026-01-24)
Executive Summary
Complete the partner dashboard and admin panel to production quality. The dashboard currently has placeholder features and requires additional RLS policies for partner-specific views. This release implements full CRUD operations, proper data filtering, and admin panel improvements.
[!IMPORTANT] Dependency on v17.0: The core RLS policies for the
servicestable are implemented in v17.0. This plan extends those policies for additional dashboard-specific tables (feedback, analytics, notifications).
Phase 1: Extended RLS Policies for Dashboard Tables (2-3 days) ✅
[!SUCCESS] Status: Complete - Ready for Testing (2026-01-22)
Implementation:
- Migration:
supabase/migrations/20260122000000_v17_4_phase1_rls_extensions.sql- Updated: Dashboard analytics, feedback pages, service list
- Documentation:
docs/implementation/v17-4-phase1-summary.md- Testing Guide:
docs/testing/v17-4-phase1-testing-guide.md- Test Queries:
docs/testing/v17-4-phase1-test-queries.sqlNext Steps: Follow testing guide before proceeding to Phase 2
1.1 Prerequisite Check
Before implementing v17.4, verify v17.0 RLS policies are active:
-- Verify services RLS is enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE tablename = 'services' AND rowsecurity = true;
-- Verify core policies exist
SELECT policyname FROM pg_policies WHERE tablename = 'services';
-- Expected: "Public can view published services", "Org members can insert services", etc.
1.2 Dashboard-Specific RLS Policies
[!NOTE] These policies extend v17.0's base policies for dashboard-specific views.
New file: supabase/migrations/XXX_dashboard_rls_policies.sql
-- Feedback table (partners see feedback on their services only)
ALTER TABLE feedback ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Partners see feedback on their services"
ON feedback
FOR SELECT
TO authenticated
USING (
service_id IN (
SELECT id FROM services
WHERE org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
)
)
);
-- Search Analytics table (partners see their service analytics)
ALTER TABLE search_analytics ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Partners see their analytics"
ON search_analytics
FOR SELECT
TO authenticated
USING (
service_id IN (
SELECT id FROM services
WHERE org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
)
)
);
Testing:
- [ ] Verify v17.0 RLS policies are working first
- [ ] Test dashboard-specific policies in isolation
- [ ] Verify data isolation between partner organizations
1.3 Dashboard Data Access Strategy: RLS-First Approach
[!IMPORTANT] Security Architecture Decision: This project uses Row Level Security (RLS) policies as the primary data filtering mechanism. Application-layer filters are NOT REQUIRED and should be avoided to prevent confusion.
Why RLS-First?
- ✅ Single Source of Truth: Security logic lives in one place (database)
- ✅ defense-in-depth: RLS protects against bugs in application code
- ✅ Performance: PostgreSQL optimizes RLS queries better than app-layer filters
- ✅ Auditability: All data access controlled by auditable RLS policies
1.3.1 Correct Pattern: Trust RLS
DO THIS:
// Dashboard Services - RLS automatically filters by organization
export async function getDashboardServices() {
const { data } = await supabase
.from("services")
.select("*")
.is("deleted_at", null) // Filter soft-deleted only
.order("updated_at", { ascending: false })
// RLS policy ensures user only sees their org's services
// No explicit org_id filter needed
return data
}
DON'T DO THIS:
// ❌ WRONG: Redundant application-layer filter
export async function getDashboardServices(orgId: string) {
const { data } = await supabase
.from("services")
.select("*")
.eq("org_id", orgId) // ❌ Unnecessary - RLS handles this
.is("deleted_at", null)
return data
}
1.3.2 Dashboard Feedback Query
DO THIS:
// app/[locale]/dashboard/feedback/page.tsx
const { data: feedback } = await supabase
.from("feedback")
.select("*, service:service_id(name, id)")
.order("created_at", { ascending: false })
// RLS policy "Partners see feedback on their services" automatically filters
Verification:
-- Verify RLS is working (run in Supabase SQL Editor)
SET ROLE authenticated;
SET request.jwt.claim.sub = 'test-user-id';
SELECT * FROM feedback;
-- Should only return feedback for services owned by test-user's org
1.3.3 Dashboard Analytics Query
DO THIS:
// app/[locale]/dashboard/analytics/page.tsx
const { data: analytics } = await supabase.from("search_analytics").select("*").gte("created_at", weekAgo)
// RLS policy "Partners see their analytics" automatically filters
Testing Strategy:
- [ ] Verify RLS policies are enabled (
SELECT tablename, rowsecurity FROM pg_tables) - [ ] Test with multiple org users to ensure data isolation
- [ ] Verify no cross-org data leakage via RLS policy tests (Phase 0 from v17.1)
[!WARNING] When to use app-layer filters: ONLY for non-security purposes like pagination, sorting, or search. Never for authorization/access control.
Phase 2: Missing Dashboard Features (4-5 days) ✅
[!SUCCESS] Status: Complete - Ready for Testing (2026-01-23)
Implementation:
- Migration:
supabase/migrations/20260123000000_v17_4_phase2_dashboard_features.sql- Settings Page: Enhanced with notification preferences and member management
- Service Creation: Full CRUD workflow with form and validation
- Member Management: Invite, manage roles, remove members
- Notifications: Already implemented (from previous migration)
- Documentation:
docs/implementation/v17-4-phase2-summary.mdNext Steps: Follow testing guide before proceeding to Phase 3
2.1 Settings Page
Problem: /dashboard/settings doesn't exist; navigation link is broken.
2.1.1 Create Settings Page
New file: app/[locale]/dashboard/settings/page.tsx
export default async function SettingsPage() {
return (
<DashboardLayout>
<SettingsForm />
</DashboardLayout>
)
}
2.1.2 SettingsForm Component
New file: components/dashboard/SettingsForm.tsx
interface SettingsFormProps {}
export function SettingsForm() {
return (
<Form>
{/* Organization Settings */}
<Section title="Organization">
<TextField label="Organization Name" />
<TextArea label="Bio/Description" maxLength={500} />
<TextField label="Website" type="url" />
<TextField label="Phone" type="tel" />
</Section>
{/* Member Settings */}
<Section title="Members">
<MemberList />
<InviteMemberButton />
</Section>
{/* Notification Preferences */}
<Section title="Notifications">
<Checkbox label="Email notifications on feedback" />
<Checkbox label="Weekly analytics report" />
</Section>
{/* Danger Zone */}
<Section title="Organization Management" variant="danger">
<Button variant="destructive">Delete Organization</Button>
</Section>
<SubmitButton>Save Settings</SubmitButton>
</Form>
)
}
Features:
- [ ] Edit organization profile
- [ ] Add/remove members (with role assignment)
- [ ] Notification preferences
- [ ] Delete organization (soft delete)
Database:
- [ ] Create
organization_settingstable - [ ] RLS: Only org members can read/write own settings
2.2 Service CRUD Operations
2.2.1 Service Deletion
Problem: No delete button in dashboard; soft delete not implemented.
Modify: app/[locale]/dashboard/services/[id]/page.tsx
export default async function ServiceDetailPage() {
return (
<ServiceDetail>
{/* existing content */}
<DangerZone>
<DeleteServiceButton serviceId={id} />
</DangerZone>
</ServiceDetail>
)
}
New file: components/dashboard/DeleteServiceButton.tsx
export function DeleteServiceButton({ serviceId }: { serviceId: string }) {
const [open, setOpen] = useState(false)
return (
<>
<Button
variant="destructive"
onClick={() => setOpen(true)}
>
Delete Service
</Button>
<ConfirmDialog
open={open}
title="Delete Service"
description="This action cannot be undone. The service will be hidden from search."
onConfirm={() => deleteService(serviceId)}
confirmText="Delete"
confirmVariant="destructive"
/>
</>
)
}
Backend: lib/actions/services.ts
export async function deleteService(serviceId: string) {
const {
data: { user },
} = await getUser()
// Verify ownership via v17.0 auth utility
await assertServiceOwnership(user.id, serviceId)
// Soft delete
const { error } = await supabase
.from("services")
.update({
deleted_at: new Date().toISOString(),
deleted_by: user.id,
})
.eq("id", serviceId)
.eq("org_id", userOrgId) // double-check ownership
if (error) throw error
return { success: true }
}
2.2.2 Public Service Creation
Problem: Only admin can create services via JSON; partners have no create endpoint.
New file: app/api/v1/services/create/route.ts
// POST /api/v1/services/create
export async function POST(request: Request) {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return createApiError("Unauthorized", 401)
const body = await request.json()
const validation = createServiceSchema.safeParse(body)
if (!validation.success) {
return createApiError("Validation failed", 400, validation.error)
}
// Create service owned by user's organization
const orgId = await getUserOrgId(user.id)
const { data, error } = await supabase
.from("services")
.insert({
...validation.data,
org_id: orgId,
created_by: user.id,
verification_level: "L1", // Default: unverified
})
.select()
.single()
if (error) return createApiError(error.message, 500)
return NextResponse.json(data)
}
Schema: lib/schemas/service-create.ts
export const createServiceSchema = z.object({
name: z.string().min(3).max(200),
name_fr: z.string().min(3).max(200),
description: z.string().min(10).max(2000),
description_fr: z.string().min(10).max(2000),
category: z.enum(['health', 'housing', ...]),
phone: z.string().optional(),
email: z.string().email().optional(),
address: z.string(),
latitude: z.number().optional(),
longitude: z.number().optional(),
website: z.string().url().optional(),
hours_text: z.string().optional(),
})
UI: app/[locale]/dashboard/services/create/page.tsx
export default function CreateServicePage() {
return (
<DashboardLayout>
<CreateServiceForm />
</DashboardLayout>
)
}
2.3 Notifications Database Integration
Problem: Notifications page shows mock data; not connected to database.
2.3.1 Create Notifications Table
New file: supabase/migrations/create_notifications_table.sql
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id),
type TEXT NOT NULL, -- 'service_feedback', 'search_alert', etc.
title TEXT NOT NULL,
message TEXT NOT NULL,
read BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT now(),
expires_at TIMESTAMP,
CONSTRAINT org_notifications FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE INDEX notifications_org_id_created_at
ON notifications(org_id, created_at DESC);
-- RLS
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Orgs see own notifications"
ON notifications
FOR SELECT
USING (
org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
)
);
2.3.2 Notification Queries
Modify: app/[locale]/dashboard/notifications/page.tsx
export default async function NotificationsPage() {
const notifications = await getNotifications()
return (
<DashboardLayout>
<NotificationsList notifications={notifications} />
</DashboardLayout>
)
}
New helper: lib/actions/notifications.ts
export async function getNotifications(limit = 50) {
const orgId = await getUserOrgId()
const { data } = await supabase
.from("notifications")
.select("*")
.eq("org_id", orgId)
.order("created_at", { ascending: false })
.limit(limit)
return data || []
}
export async function markNotificationRead(id: string) {
const { error } = await supabase.from("notifications").update({ read: true }).eq("id", id)
if (error) throw error
}
Phase 3: Admin Panel Improvements (3-4 days) ✅
[!SUCCESS] Status: Complete - Ready for Testing (2026-01-24)
Implementation:
- Migration:
supabase/migrations/20260124000000_v17_4_phase3_admin_improvements.sql- Enhanced: Admin service form with all missing fields
- Created: Reindex progress tracking system
- Updated: Admin save endpoint with action logging
- Updated: Push endpoint with segment targeting
- Component: ReindexProgress with live progress display
- Documentation:
docs/implementation/v17-4-phase3-summary.mdNext Steps: Follow testing guide before proceeding to Phase 4
Phase 3: Admin Panel Improvements (3-4 days)
3.1 Admin Save to Database
Problem: Admin panel writes to JSON file, not Supabase.
3.1.1 Implement Database Save
Modify: app/api/admin/save/route.ts
Current:
// Writes to JSON file
const filePath = join(process.cwd(), "data", "services.json")
fs.writeFileSync(filePath, JSON.stringify(services, null, 2))
Required:
export async function POST(request: Request) {
// Verify admin
await assertAdminRole(user.id)
const { services } = await request.json()
// Upsert to Supabase
const { error } = await supabase.from("services").upsert(services, { onConflict: "id" })
if (error) return createApiError(error.message, 500)
// Also save to JSON for offline mode
fs.writeFileSync(jsonPath, JSON.stringify(services, null, 2))
// Trigger reindex
await triggerEmbeddingGeneration()
return NextResponse.json({ success: true })
}
3.2 Reindex Progress Tracking
Problem: Embedding generation provides no progress feedback.
3.2.1 Create Progress Table
New file: supabase/migrations/create_reindex_progress.sql
CREATE TABLE reindex_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
started_at TIMESTAMP DEFAULT now(),
total_services INT,
processed_count INT DEFAULT 0,
status TEXT DEFAULT 'running', -- 'running', 'complete', 'error'
error_message TEXT,
completed_at TIMESTAMP,
);
CREATE INDEX reindex_progress_started_at
ON reindex_progress(started_at DESC);
3.2.2 Update Admin Endpoints
Modify: app/api/admin/reindex/route.ts
export async function POST(request: Request) {
await assertAdminRole(user.id)
const progressId = crypto.randomUUID()
// Create progress record
await supabase.from("reindex_progress").insert({
id: progressId,
total_services: serviceCount,
})
// Trigger generation in background
triggerEmbeddingGenerationWithProgress(progressId)
return NextResponse.json({ progressId })
}
3.2.3 Progress Query Endpoint
New file: app/api/admin/reindex/status/route.ts
// GET /api/admin/reindex/status?progressId=...
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const progressId = searchParams.get("progressId")
const { data } = await supabase.from("reindex_progress").select("*").eq("id", progressId).single()
return NextResponse.json(data)
}
UI: components/admin/ReindexProgress.tsx
export function ReindexProgress({ progressId }: { progressId: string }) {
const [progress, setProgress] = useState(null)
useEffect(() => {
const interval = setInterval(() => {
fetch(`/api/admin/reindex/status?progressId=${progressId}`)
.then(r => r.json())
.then(setProgress)
}, 1000)
return () => clearInterval(interval)
}, [progressId])
return (
<div>
<ProgressBar
value={progress?.processed_count}
max={progress?.total_services}
/>
<p>{progress?.processed_count} / {progress?.total_services}</p>
{progress?.status === 'complete' && <CheckIcon />}
{progress?.error_message && <ErrorAlert>{progress.error_message}</ErrorAlert>}
</div>
)
}
3.3 OneSignal Targeting
Problem: Admin pushes to "All" subscribers; no segment targeting.
3.3.1 Segment Configuration
Modify: app/api/admin/push/route.ts
interface PushRequest {
title: string
message: string
target: "all" | "offline_users" | "high_engagement" | "new_users"
filters?: {
createdAfter?: Date
createdBefore?: Date
minSessions?: number
}
}
export async function POST(request: Request) {
await assertAdminRole(user.id)
const { title, message, target, filters } = await request.json()
// Build OneSignal segment filters
const segmentFilters = buildOneSignalFilters(target, filters)
const response = await oneSignalClient.createNotification({
included_segments: [target],
filters: segmentFilters,
headings: { en: title },
contents: { en: message },
})
return NextResponse.json(response)
}
3.4 Complete Service Form
Problem: Admin form missing fields (hours, fees, eligibility).
Modify: components/admin/ServiceForm.tsx
Add sections:
- [ ] Hours (structured format with day/time pairs)
- [ ] Phone number(s)
- [ ] Email address
- [ ] Website
- [ ] Fees (free, sliding scale, cost)
- [ ] Eligibility criteria
- [ ] Access methods (phone, in-person, online)
Phase 4: RBAC Implementation (2-3 days) ✅
[!SUCCESS] Status: Complete - Ready for Testing (2026-01-24)
Implementation:
- RBAC Utilities:
lib/rbac.ts(285 lines) - Permission matrix and role hierarchy- Member Actions:
lib/actions/members.ts(271 lines) - Role management server actions- React Hook:
hooks/useRBAC.ts(79 lines) - Memoized permission checks for UI- Enhanced:
components/dashboard/MemberManagement.tsxwith ownership transfer- Updated:
lib/actions/services.tswith permission enforcement- Documentation:
docs/implementation/v17-4-phase4-summary.md(comprehensive guide)Key Features:
- 4-tier role hierarchy (owner > admin > editor > viewer)
- 19 granular permissions across 5 categories
- Defense-in-depth security (UI + Server Actions + RLS)
- Ownership transfer with safety checks and rollback
- Dynamic role assignment UI with permission-based visibility
No Database Migration Required - Uses existing
organization_memberstable from Phase 2Next Steps: Follow the testing guide in
docs/implementation/v17-4-phase4-summary.md
4.1 Role Hierarchy
Modify: types/organization.ts
export type OrganizationRole = "owner" | "admin" | "editor" | "viewer"
interface OrganizationMember {
user_id: string
org_id: string
role: OrganizationRole
added_at: string
added_by: string
}
// Permissions per role:
// owner: Can do everything, transfer ownership
// admin: Can manage services, members, settings
// editor: Can create/edit/delete own services
// viewer: Can view services, analytics (read-only)
4.2 Role-Based Access Control
New file: lib/rbac.ts
export async function requireRole(userId: string, orgId: string, requiredRole: OrganizationRole): Promise<void> {
const member = await getOrganizationMember(userId, orgId)
const roleHierarchy = {
owner: 4,
admin: 3,
editor: 2,
viewer: 1,
}
if (roleHierarchy[member.role] < roleHierarchy[requiredRole]) {
throw new Error(`Requires ${requiredRole} role`)
}
}
4.3 Member Management UI
New file: components/dashboard/MemberManagement.tsx
export function MemberManagement() {
return (
<Section>
<MembersList />
<InviteMemberForm />
</Section>
)
}
Features:
- [ ] List organization members
- [ ] Show member roles
- [ ] Change member role
- [ ] Remove member
- [ ] Invite new members (by email)
- [ ] Resend invitations
Backend: lib/actions/members.ts
export async function inviteMember(email: string, role: OrganizationRole) {
const orgId = await getUserOrgId()
// Create invitation record
const { data: invitation } = await supabase
.from("organization_invitations")
.insert({
org_id: orgId,
email,
role,
invited_by: userId,
})
.select()
.single()
// Send invite email
await sendInviteEmail(email, orgId, invitation.token)
return invitation
}
Testing & Verification
Unit Tests
New file: tests/lib/actions/services.test.ts
describe('getDashboardServices', () => {
it('returns only services for user's organization')
it('excludes soft-deleted services')
it('returns empty array for new organization')
})
describe('deleteService', () => {
it('marks service as deleted')
it('prevents non-owners from deleting')
})
describe('createService', () => {
it('creates service owned by user's org')
it('validates required fields')
it('sets L1 verification level')
})
New file: tests/lib/rbac.test.ts
describe("requireRole", () => {
it("allows owner to do any action")
it("allows admin to manage services")
it("prevents editor from managing members")
it("prevents viewer from editing")
})
Integration Tests
Modify: tests/integration/dashboard-workflows.test.ts
describe("Partner Dashboard Workflow", () => {
it("partner logs in")
it("sees only their services")
it("can create new service")
it("can edit service")
it("can delete service")
it("can view feedback on own services")
})
describe("Member Management Workflow", () => {
it("owner invites new member")
it("member accepts invitation")
it("member has correct permissions")
it("owner can change member role")
})
E2E Tests
Modify: tests/e2e/dashboard.spec.ts
Add scenarios:
- [ ] Partner signs up → creates service → sees in dashboard
- [ ] Partner edits service → changes appear in search
- [ ] Partner deletes service → hidden from search
- [ ] Admin reindexes → progress updates
- [ ] Admin sends push notification → appears in OneSignal
Database Changes Summary
| Table | Action | Source | Purpose |
|---|---|---|---|
| services | RLS policy | v17.0 | Base authorization (SELECT/INSERT/UPDATE/DELETE) |
| organization_members | NEW + RLS | v17.0 | Role-based membership |
| audit_logs | NEW + RLS | v17.0 | Security audit trail |
| feedback | RLS policy | v17.2 | Filter by service org_id |
| search_analytics | RLS policy | v17.2 | Filter by service org_id |
| organization_settings | NEW | v17.2 | Store org preferences |
| notifications | NEW | v17.2 | Database notifications |
| reindex_progress | NEW | v17.2 | Track embedding generation |
| organization_invitations | NEW | v17.2 | Member invitations |
Success Criteria
- [ ] v17.0 RLS policies verified before starting
- [ ] All dashboard links navigable
- [ ] Partners see only their own data (verified with test users)
- [ ] Full service CRUD working
- [ ] Settings page functional
- [ ] Member management working (invite, role change, remove)
- [ ] Admin panel uses Supabase instead of JSON
- [ ] Progress tracking for reindex operations
- [ ] Zero privilege escalation possible (re-verify after v17.0)
Dependency Graph
v17.0 (Security)
├── RLS on services table
├── organization_members table
└── Authorization utility
│
▼
v17.2 (Dashboard)
├── Extended RLS for feedback, analytics
├── Dashboard UI features
└── Admin panel improvements
Rollback Plan
- RLS Policies: Policies are additive; can disable specific policies without removing v17.0 base
- New Tables: Can be soft-deleted or dropped if issues arise
- UI Features: Deploy behind feature flag if uncertain
- Revert to v17.0: Dashboard still works with base security, just missing new features