Supabase Backend
MySched uses Supabase as its backend-as-a-service, providing PostgreSQL database, authentication, Edge Functions, and realtime subscriptions. All data access is secured with Row-Level Security (RLS).
Database Overview
| Metric | Value |
|---|---|
| Total tables | 28 |
| RLS-enabled tables | 28 (100%) |
| SECURITY DEFINER functions | 64 |
| Anon-callable functions | 6 |
| Edge Functions | Active for push notifications, OCR processing |
Core Tables
class_items
Stores all class schedule entries. See Data Models for the full schema.
CREATE TABLE class_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) NOT NULL,
name text NOT NULL,
description text,
instructor text,
room text,
section text NOT NULL,
day_of_week int4 NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
start_time time NOT NULL,
end_time time NOT NULL,
color text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
is_deleted bool DEFAULT false
);reminders
Reminder entries linked to classes or standalone.
sections
Named schedule groups (e.g., "Fall 2024").
user_settings
Key-value store for cross-device settings sync.
CREATE TABLE user_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) NOT NULL,
key text NOT NULL,
value jsonb NOT NULL,
updated_at timestamptz DEFAULT now(),
UNIQUE(user_id, key)
);user_devices
Tracks authenticated devices for session management and remote revocation.
CREATE TABLE user_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) NOT NULL,
device_name text,
device_platform text, -- 'ios', 'android'
push_token text,
last_active_at timestamptz DEFAULT now(),
is_revoked bool DEFAULT false,
created_at timestamptz DEFAULT now()
);Row-Level Security
Every table has RLS enabled. Policies follow a consistent pattern — users can only access their own data:
-- Standard SELECT policy (applied to all user-scoped tables)
CREATE POLICY "Users can view own data"
ON class_items FOR SELECT
USING (auth.uid() = user_id);
-- Standard INSERT policy
CREATE POLICY "Users can insert own data"
ON class_items FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Standard UPDATE policy
CREATE POLICY "Users can update own data"
ON class_items FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Soft delete only (no hard deletes via API)
CREATE POLICY "Users can soft-delete own data"
ON class_items FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id AND is_deleted = true);Policy conventions:
- No
DELETEpolicies on user data tables — soft deletes only SECURITY DEFINERfunctions bypass RLS for administrative operations- Only 6 functions are callable by the
anonrole (public endpoints) - All other functions require an authenticated JWT
SECURITY DEFINER Functions
Complex operations use server-side functions to ensure atomicity and bypass RLS when needed:
-- Example: Delete a section and cascade soft-delete its classes
CREATE OR REPLACE FUNCTION delete_section_cascade(p_section_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Verify ownership
IF NOT EXISTS (
SELECT 1 FROM sections
WHERE id = p_section_id AND user_id = auth.uid()
) THEN
RAISE EXCEPTION 'Section not found or access denied';
END IF;
-- Soft-delete all classes in section
UPDATE class_items
SET is_deleted = true, updated_at = now()
WHERE section = p_section_id AND user_id = auth.uid();
-- Soft-delete the section
UPDATE sections
SET is_deleted = true, updated_at = now()
WHERE id = p_section_id;
END;
$$;API Patterns
Querying Data (Client-Side)
// Fetch all active classes for a section
final response = await supabase
.from('class_items')
.select()
.eq('section', sectionId)
.eq('is_deleted', false)
.order('day_of_week')
.order('start_time');
final classes = (response as List)
.map((json) => ClassItem.fromJson(json))
.toList();Calling RPC Functions
// Call a SECURITY DEFINER function
await supabase.rpc('delete_section_cascade', params: {
'p_section_id': sectionId,
});Realtime Subscriptions
// Listen for changes to class items
final subscription = supabase
.from('class_items')
.stream(primaryKey: ['id'])
.eq('user_id', currentUserId)
.listen((List<Map<String, dynamic>> data) {
// Update local state
_updateClassList(data);
});Edge Functions
Supabase Edge Functions handle server-side logic that cannot run on the client:
| Function | Purpose | Auth Required |
|---|---|---|
send-push-notification | Delivers push notifications via FCM/APNs | Yes |
process-ocr-result | Post-processes OCR text into structured schedule data | Yes |
cleanup-expired | Purges soft-deleted records past retention period | Service role only |
export-schedule | Generates PDF/ICS export files | Yes |
// Example Edge Function: send-push-notification
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
serve(async (req) => {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { userId, title, body } = await req.json();
// Get user's push tokens
const { data: devices } = await supabase
.from("user_devices")
.select("push_token")
.eq("user_id", userId)
.eq("is_revoked", false)
.not("push_token", "is", null);
// Send to each device...
return new Response(JSON.stringify({ sent: devices?.length ?? 0 }));
});Auth State Listener
Supabase auth state changes are serialized to prevent race conditions during rapid auth events:
class AuthStateManager {
final _eventQueue = StreamController<AuthChangeEvent>();
bool _processing = false;
void initialize() {
supabase.auth.onAuthStateChange.listen((data) {
_eventQueue.add(data.event);
_processQueue();
});
}
Future<void> _processQueue() async {
if (_processing) return;
_processing = true;
await for (final event in _eventQueue.stream) {
switch (event) {
case AuthChangeEvent.signedIn:
await _handleSignIn();
break;
case AuthChangeEvent.signedOut:
await _handleSignOut();
break;
case AuthChangeEvent.tokenRefreshed:
await _handleTokenRefresh();
break;
}
}
_processing = false;
}
}