feat(chat): integrate chat project into monorepo with full app structure

- Restructure chat as apps/mobile, apps/web, apps/landing, backend
- Add NestJS backend for secure Azure OpenAI API calls
- Remove exposed API key from mobile app (security fix)
- Add shared chat-types package
- Create SvelteKit web app scaffold
- Create Astro landing page scaffold
- Update pnpm workspace configuration
- Add project-level CLAUDE.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 13:48:24 +01:00
parent fcf3a344b1
commit c638a7ffee
155 changed files with 22622 additions and 348 deletions

View file

@ -0,0 +1,176 @@
-- Enable Row Level Security for spaces tables
ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY;
-- RLS policies for spaces
-- Space owners can do everything with their spaces
CREATE POLICY spaces_owner_policy
ON public.spaces
TO authenticated
USING (owner_id = auth.uid());
-- Members can view spaces they belong to
CREATE POLICY spaces_member_select_policy
ON public.spaces
FOR SELECT
TO authenticated
USING (
id IN (
SELECT space_id
FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
);
-- RLS policies for space_members
-- Space owners can manage all members
CREATE POLICY space_members_owner_policy
ON public.space_members
TO authenticated
USING (
space_id IN (
SELECT id FROM public.spaces WHERE owner_id = auth.uid()
)
);
-- Space admins can manage members (except owners)
CREATE POLICY space_members_admin_policy
ON public.space_members
TO authenticated
USING (
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND role = 'admin' AND invitation_status = 'accepted'
)
AND role != 'owner'
);
-- Users can see which spaces they are members of
CREATE POLICY space_members_self_select_policy
ON public.space_members
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Users can accept/decline their own invitations
CREATE POLICY space_members_invitation_update_policy
ON public.space_members
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (
user_id = auth.uid()
AND (OLD.invitation_status = 'pending')
AND (NEW.invitation_status IN ('accepted', 'declined'))
AND (OLD.role = NEW.role)
AND (OLD.space_id = NEW.space_id)
AND (OLD.user_id = NEW.user_id)
);
-- Update RLS policies for conversations
-- Modify existing policies to include space-based access
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR
(
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
)
);
-- Allow space members to create conversations in spaces they belong to
CREATE POLICY conversations_space_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (
user_id = auth.uid()
AND
(
space_id IS NULL
OR
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
)
);
-- Allow updates to conversations in spaces based on role
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (
user_id = auth.uid()
OR
(
space_id IN (
SELECT sm.space_id FROM public.space_members sm
WHERE sm.user_id = auth.uid()
AND sm.invitation_status = 'accepted'
AND sm.role IN ('owner', 'admin')
)
)
);
-- Allow deletion of conversations in spaces based on role
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (
user_id = auth.uid()
OR
(
space_id IN (
SELECT sm.space_id FROM public.space_members sm
WHERE sm.user_id = auth.uid()
AND sm.invitation_status = 'accepted'
AND sm.role IN ('owner', 'admin')
)
)
);
-- Helper function to check if a user has access to a space
CREATE OR REPLACE FUNCTION public.user_has_space_access(space_uuid UUID, role_level TEXT DEFAULT 'viewer')
RETURNS BOOLEAN AS $$
DECLARE
has_access BOOLEAN;
role_hierarchy TEXT[];
BEGIN
-- Define role hierarchy from highest to lowest
role_hierarchy := ARRAY['owner', 'admin', 'member', 'viewer'];
-- Find position of requested role in hierarchy
WITH role_positions AS (
SELECT
unnest(role_hierarchy) AS role,
row_number() OVER () AS position
)
SELECT EXISTS (
SELECT 1 FROM public.space_members sm
JOIN role_positions rp1 ON sm.role = rp1.role
JOIN role_positions rp2 ON rp2.role = role_level
WHERE sm.space_id = space_uuid
AND sm.user_id = auth.uid()
AND sm.invitation_status = 'accepted'
AND rp1.position <= rp2.position -- Check if user's role is at least the required level
) INTO has_access;
RETURN has_access;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -0,0 +1,45 @@
-- Create spaces table
CREATE TABLE IF NOT EXISTS public.spaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
owner_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_archived BOOLEAN DEFAULT false
);
-- Add comments for documentation
COMMENT ON TABLE public.spaces IS 'Collaborative spaces for organizing conversations';
COMMENT ON COLUMN spaces.name IS 'Name of the space';
COMMENT ON COLUMN spaces.description IS 'Optional description of the space';
COMMENT ON COLUMN spaces.owner_id IS 'User ID of the space owner';
COMMENT ON COLUMN spaces.is_archived IS 'Indicates whether the space is archived';
-- Create space_members table with roles/permissions
CREATE TABLE IF NOT EXISTS public.space_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
invitation_status TEXT NOT NULL DEFAULT 'pending' CHECK (invitation_status IN ('pending', 'accepted', 'declined')),
invited_by UUID REFERENCES public.users(id),
invited_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
joined_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(space_id, user_id)
);
-- Add comments for space_members
COMMENT ON TABLE public.space_members IS 'Members of collaborative spaces with defined roles';
COMMENT ON COLUMN space_members.role IS 'Role of the user in the space (owner, admin, member, viewer)';
COMMENT ON COLUMN space_members.invitation_status IS 'Status of the invitation (pending, accepted, declined)';
-- Modify conversations table to add space_id
ALTER TABLE public.conversations
ADD COLUMN IF NOT EXISTS space_id UUID REFERENCES public.spaces(id) ON DELETE SET NULL;
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_conversations_space_id ON conversations(space_id);
CREATE INDEX IF NOT EXISTS idx_conversations_space_user ON conversations(space_id, user_id);

View file

@ -0,0 +1,97 @@
-- Create updated_at trigger for spaces
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply updated_at trigger to spaces table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'set_spaces_updated_at'
) THEN
CREATE TRIGGER set_spaces_updated_at
BEFORE UPDATE ON public.spaces
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
END IF;
END
$$;
-- Apply updated_at trigger to space_members table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'set_space_members_updated_at'
) THEN
CREATE TRIGGER set_space_members_updated_at
BEFORE UPDATE ON public.space_members
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
END IF;
END
$$;
-- Automatically add space owner as member with owner role
CREATE OR REPLACE FUNCTION add_owner_to_space_members()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.space_members (
space_id,
user_id,
role,
invitation_status,
joined_at
)
VALUES (
NEW.id,
NEW.owner_id,
'owner',
'accepted',
NOW()
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply owner trigger to spaces table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'add_owner_to_space_members_trigger'
) THEN
CREATE TRIGGER add_owner_to_space_members_trigger
AFTER INSERT ON public.spaces
FOR EACH ROW
EXECUTE FUNCTION add_owner_to_space_members();
END IF;
END
$$;
-- Update space modification timestamp when members are added/changed
CREATE OR REPLACE FUNCTION update_space_timestamp_on_member_change()
RETURNS TRIGGER AS $$
BEGIN
UPDATE public.spaces
SET updated_at = NOW()
WHERE id = NEW.space_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply space timestamp update trigger if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'update_space_timestamp_trigger'
) THEN
CREATE TRIGGER update_space_timestamp_trigger
AFTER INSERT OR UPDATE ON public.space_members
FOR EACH ROW
EXECUTE FUNCTION update_space_timestamp_on_member_change();
END IF;
END
$$;

View file

@ -0,0 +1,86 @@
-- Drop problematic policies that cause infinite recursion
DROP POLICY IF EXISTS space_members_owner_policy ON space_members;
DROP POLICY IF EXISTS space_members_admin_policy ON space_members;
DROP POLICY IF EXISTS space_members_self_select_policy ON space_members;
DROP POLICY IF EXISTS space_members_invitation_update_policy ON space_members;
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
DROP POLICY IF EXISTS conversations_space_insert_policy ON conversations;
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
-- Recreate RLS policies for space_members (simplified to avoid recursion)
CREATE POLICY space_members_owner_policy
ON public.space_members
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.spaces
WHERE id = space_id AND owner_id = auth.uid()
)
);
-- Users can see which spaces they are members of
CREATE POLICY space_members_self_select_policy
ON public.space_members
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Users can accept/decline their own invitations
CREATE POLICY space_members_invitation_update_policy
ON public.space_members
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (
user_id = auth.uid()
AND invitation_status = 'pending'
);
-- Create simplified policies for conversations
-- Allow users to see their own conversations or shared with them
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
);
-- Allow users to create conversations in spaces they belong to
CREATE POLICY conversations_space_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (
user_id = auth.uid()
AND
(
space_id IS NULL
OR
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
)
);
-- Allow users to update their own conversations
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (user_id = auth.uid());
-- Allow users to delete their own conversations
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (user_id = auth.uid());

View file

@ -0,0 +1,69 @@
-- Completely drop ALL RLS policies for the affected tables
DROP POLICY IF EXISTS spaces_owner_policy ON spaces;
DROP POLICY IF EXISTS spaces_member_select_policy ON spaces;
DROP POLICY IF EXISTS space_members_owner_policy ON space_members;
DROP POLICY IF EXISTS space_members_admin_policy ON space_members;
DROP POLICY IF EXISTS space_members_self_select_policy ON space_members;
DROP POLICY IF EXISTS space_members_invitation_update_policy ON space_members;
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
DROP POLICY IF EXISTS conversations_space_insert_policy ON conversations;
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
-- Create minimal basic policies for spaces
CREATE POLICY spaces_select_policy
ON public.spaces
FOR SELECT
TO authenticated
USING (true); -- Allow all users to see all spaces for now
CREATE POLICY spaces_insert_policy
ON public.spaces
FOR INSERT
TO authenticated
WITH CHECK (owner_id = auth.uid()); -- Only allow users to create spaces where they are the owner
-- Create minimal basic policies for space_members
CREATE POLICY space_members_select_policy
ON public.space_members
FOR SELECT
TO authenticated
USING (true); -- Allow all users to see all space members for now
CREATE POLICY space_members_insert_policy
ON public.space_members
FOR INSERT
TO authenticated
WITH CHECK (true); -- Allow all insertions for now
CREATE POLICY space_members_update_policy
ON public.space_members
FOR UPDATE
TO authenticated
USING (true) -- Allow all updates for now
WITH CHECK (true);
-- Revert conversations back to simple user-based policies
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (user_id = auth.uid()); -- Only see your own conversations
CREATE POLICY conversations_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid()); -- Only create your own conversations
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (user_id = auth.uid()); -- Only update your own conversations
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (user_id = auth.uid()); -- Only delete your own conversations

View file

@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* This script sets up the spaces feature by running the necessary SQL scripts
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { createClient } = require('@supabase/supabase-js');
// Get environment variables
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY;
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
console.error('Error: SUPABASE_URL and SUPABASE_SERVICE_KEY environment variables must be set');
process.exit(1);
}
// Create Supabase client
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
async function executeSQL(filename) {
try {
const filePath = path.join(__dirname, filename);
const sql = fs.readFileSync(filePath, 'utf8');
// Split the SQL file by semicolons to get individual statements
const statements = sql
.split(';')
.map(statement => statement.trim())
.filter(statement => statement.length > 0);
console.log(`Executing ${statements.length} statements from ${filename}...`);
for (const statement of statements) {
const { error } = await supabase.rpc('exec_sql', { sql: statement });
if (error) {
console.error(`Error executing statement:`, error);
console.error(`Statement was: ${statement.substring(0, 100)}...`);
}
}
console.log(`✅ Successfully executed ${filename}`);
return true;
} catch (error) {
console.error(`❌ Error executing ${filename}:`, error);
return false;
}
}
async function main() {
console.log('Setting up spaces feature...');
// Run the SQL scripts in the correct order
const scripts = [
'create_spaces_tables.sql',
'create_spaces_triggers.sql',
'create_spaces_rls.sql'
];
for (const script of scripts) {
const success = await executeSQL(script);
if (!success) {
console.error(`Failed to execute ${script}. Aborting.`);
process.exit(1);
}
}
console.log('✅ Spaces feature setup complete!');
}
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});