diff --git a/api/package.json b/api/package.json index 1579f76..b3f4bb6 100644 --- a/api/package.json +++ b/api/package.json @@ -2,9 +2,9 @@ "type": "module", "name": "xtablo-api", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "NODE_ENV=development tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "NODE_ENV=production node dist/index.js" }, "dependencies": { "@hono/node-server": "^1.14.4", diff --git a/api/src/config.ts b/api/src/config.ts new file mode 100644 index 0000000..ebc596c --- /dev/null +++ b/api/src/config.ts @@ -0,0 +1,82 @@ +export interface AppConfig { + NODE_ENV: "development" | "production" | "test"; + PORT: number; + FRONTEND_URL: string; + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + STREAM_CHAT_API_KEY: string; + STREAM_CHAT_API_SECRET: string; + EMAIL_USER: string; + EMAIL_KEY: string; + XTABLO_URL: string; + CORS_ORIGIN: string[]; + LOG_LEVEL: "debug" | "info" | "warn" | "error"; +} + +function validateEnvVar(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function createConfig(): AppConfig { + const NODE_ENV = (process.env.NODE_ENV || "development") as + | "development" + | "production" + | "test"; + + // Base configuration + const baseConfig: AppConfig = { + NODE_ENV, + PORT: parseInt(process.env.PORT || "8080", 10), + FRONTEND_URL: process.env.FRONTEND_URL || "http://localhost:5173", + SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL), + SUPABASE_SERVICE_ROLE_KEY: validateEnvVar( + "SUPABASE_SERVICE_ROLE_KEY", + process.env.SUPABASE_SERVICE_ROLE_KEY + ), + STREAM_CHAT_API_KEY: validateEnvVar( + "STREAM_CHAT_API_KEY", + process.env.STREAM_CHAT_API_KEY + ), + STREAM_CHAT_API_SECRET: validateEnvVar( + "STREAM_CHAT_API_SECRET", + process.env.STREAM_CHAT_API_SECRET + ), + EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER), + EMAIL_KEY: validateEnvVar("EMAIL_KEY", process.env.EMAIL_KEY), + CORS_ORIGIN: [], + XTABLO_URL: process.env.XTABLO_URL || "https://xtablo.com", + LOG_LEVEL: "info", + }; + + // Environment-specific configurations + if (NODE_ENV === "development") { + baseConfig.CORS_ORIGIN = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + ]; + baseConfig.LOG_LEVEL = "debug"; + } else if (NODE_ENV === "production") { + baseConfig.CORS_ORIGIN = [ + baseConfig.FRONTEND_URL, + "https://xtablo.com", + "https://develop.xtablo-source.pages.dev", + ]; + baseConfig.LOG_LEVEL = "info"; + } else if (NODE_ENV === "test") { + baseConfig.CORS_ORIGIN = ["http://localhost:3000"]; + baseConfig.LOG_LEVEL = "warn"; + } + + return baseConfig; +} + +export const config = createConfig(); + +// Helper functions for common environment checks +export const isDevelopment = () => config.NODE_ENV === "development"; +export const isProduction = () => config.NODE_ENV === "production"; diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 28a1385..a498a4c 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -108,29 +108,26 @@ export type Database = { } Relationships: [] } - tablo_invitations: { + tablo_invites: { Row: { - created_at: string | null id: number + invite_token: string invited_by: string invited_email: string - status: string tablo_id: number } Insert: { - created_at?: string | null id?: number + invite_token: string invited_by: string invited_email: string - status?: string tablo_id: number } Update: { - created_at?: string | null id?: number + invite_token?: string invited_by?: string invited_email?: string - status?: string tablo_id?: number } Relationships: [ @@ -151,9 +148,9 @@ export type Database = { id: number image: string | null name: string + owner_id: string position: number status: string - user_id: string } Insert: { color?: string | null @@ -162,9 +159,9 @@ export type Database = { id?: number image?: string | null name: string + owner_id: string position?: number status?: string - user_id: string } Update: { color?: string | null @@ -173,9 +170,9 @@ export type Database = { id?: number image?: string | null name?: string + owner_id?: string position?: number status?: string - user_id?: string } Relationships: [] } diff --git a/api/src/index.ts b/api/src/index.ts index 520cade..d54d482 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -40,5 +40,4 @@ serve( console.log(`Server is running on http://localhost:${info.port}`); } ); - // TODO: Add health check endpoint diff --git a/api/src/middleware.ts b/api/src/middleware.ts index 31af78d..d36858b 100644 --- a/api/src/middleware.ts +++ b/api/src/middleware.ts @@ -31,7 +31,7 @@ export const authMiddleware = async (c: Context, next: Next) => { export const supabaseMiddleware = async (c: Context, next: Next) => { const supabase = createClient( process.env.SUPABASE_URL as string, - process.env.SUPABASE_ANON_KEY as string + process.env.SUPABASE_SERVICE_ROLE_KEY as string ); c.set("supabase", supabase); await next(); diff --git a/api/src/token.ts b/api/src/token.ts new file mode 100644 index 0000000..97745fc --- /dev/null +++ b/api/src/token.ts @@ -0,0 +1,9 @@ +import crypto from "crypto"; + +export const generateToken = (): string => { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(36).padStart(2, "0")).join( + "" + ); +}; diff --git a/api/src/user.ts b/api/src/user.ts index 3433ab3..7286ffe 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -3,6 +3,8 @@ import { authMiddleware, emailMiddleware } from "./middleware.js"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import { StreamChat } from "stream-chat"; import type { Transporter } from "nodemailer"; +import { generateToken } from "./token.js"; +import { config } from "./config.js"; export const userRouter = new Hono<{ Variables: { @@ -36,25 +38,52 @@ userRouter.get("/get-stream-token", async (c) => { userRouter.post("/invite", async (c) => { const sender = c.get("user"); - // const supabase = c.get("supabase"); + const supabase = c.get("supabase"); const transporter = c.get("transporter"); const { email: recipientmail, tablo_id } = await c.req.json(); + const token = generateToken(); + + const { data: tablo, error: tabloError } = await supabase + .from("tablos") + .select("*") + .eq("id", tablo_id) + .single(); + + if (tabloError) { + return c.json({ error: tabloError.message }, 500); + } + + if (!tablo) { + return c.json({ error: "Tablo not found" }, 404); + } + + if (tablo.user_id !== sender.id) { + return c.json( + { error: "You are not allowed to invite users to this tablo" }, + 400 + ); + } + + const { error } = await supabase.from("tablo_invites").insert({ + invited_email: recipientmail, + tablo_id: tablo_id, + invited_by: sender.id, + invite_token: token, + }); + + if (error) { + return c.json({ error: error.message }, 500); + } + const info = await transporter.sendMail({ from: `${sender.email} via XTablo `, to: recipientmail, - subject: "You have been invited to a tablo", - html: `

You have been invited to a tablo with the following link: https://xtablo.com/tablo/${tablo_id}

`, + subject: "Vous avez été invité à un tablo", + html: `

Vous avez été invité à un tablo avec ce lien

`, }); - // const { data, error } = await supabase.auth.admin.inviteUserByEmail( - // recipientmail, - // { - // data: { - // tablo_id, - // }, - // } - // ); - - return c.json({ data: info }); + return c.json({ + message: "Invite sent successfully", + }); }); diff --git a/justfile b/justfile index 6802d13..1a570bc 100644 --- a/justfile +++ b/justfile @@ -14,4 +14,7 @@ _api-dev: cd api && npm run dev dev: - just _api-dev & (just _frontend-dev) \ No newline at end of file + just _api-dev & (just _frontend-dev) + +update-types: + npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > ui/src/types/database.types.ts && cp ui/src/types/database.types.ts api/src/database.types.ts diff --git a/sql/05_create_tablos_schema.sql b/sql/05_create_tablos_schema.sql deleted file mode 100644 index 09ae7c7..0000000 --- a/sql/05_create_tablos_schema.sql +++ /dev/null @@ -1,294 +0,0 @@ --- ===================================================== --- TABLOS SYSTEM DATABASE SCHEMA --- ===================================================== - --- 1. TABLOS TABLE --- Main table for storing tablo/workspace information -CREATE TABLE tablos ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - description TEXT, - color VARCHAR(50) NOT NULL DEFAULT 'bg-blue-500', - owner_id UUID NOT NULL REFERENCES auth.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, - is_public BOOLEAN DEFAULT FALSE, - settings JSONB DEFAULT '{}', - - -- Indexes for performance - CONSTRAINT tablos_name_not_empty CHECK (LENGTH(TRIM(name)) > 0), - CONSTRAINT tablos_color_format CHECK (color ~ '^bg-[a-z]+-[0-9]+$') -); - --- 2. TABLO MEMBERS TABLE --- Junction table for tablo membership and permissions -CREATE TABLE tablo_members ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tablo_id UUID NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - role VARCHAR(50) NOT NULL DEFAULT 'member', - permissions JSONB DEFAULT '{"read": true, "write": false, "admin": false}', - joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - invited_by UUID REFERENCES auth.users(id), - - -- Ensure unique membership per tablo - UNIQUE(tablo_id, user_id), - - -- Valid roles constraint - CONSTRAINT valid_member_role CHECK (role IN ('owner', 'admin', 'member', 'viewer')) -); - --- 3. TABLO BOARDS TABLE --- Different boards within a tablo (like Kanban boards, calendars, etc.) -CREATE TABLE tablo_boards ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tablo_id UUID NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - type VARCHAR(50) NOT NULL DEFAULT 'kanban', - description TEXT, - position INTEGER NOT NULL DEFAULT 0, - settings JSONB DEFAULT '{}', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES auth.users(id), - - -- Valid board types - CONSTRAINT valid_board_type CHECK (type IN ('kanban', 'calendar', 'table', 'timeline', 'chat')) -); - --- 4. TABLO LISTS TABLE --- Lists/columns within boards (for Kanban-style organization) -CREATE TABLE tablo_lists ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - board_id UUID NOT NULL REFERENCES tablo_boards(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - color VARCHAR(50), - settings JSONB DEFAULT '{}', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- 5. TABLO CARDS TABLE --- Individual cards/items within lists -CREATE TABLE tablo_cards ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - list_id UUID NOT NULL REFERENCES tablo_lists(id) ON DELETE CASCADE, - title VARCHAR(500) NOT NULL, - description TEXT, - position INTEGER NOT NULL DEFAULT 0, - due_date TIMESTAMP WITH TIME ZONE, - priority VARCHAR(20) DEFAULT 'medium', - labels JSONB DEFAULT '[]', - assignees JSONB DEFAULT '[]', - attachments JSONB DEFAULT '[]', - checklist JSONB DEFAULT '[]', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES auth.users(id), - - -- Valid priority levels - CONSTRAINT valid_priority CHECK (priority IN ('low', 'medium', 'high', 'urgent')) -); - --- 6. TABLO ACTIVITIES TABLE --- Activity log for tracking changes and actions -CREATE TABLE tablo_activities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tablo_id UUID NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - action VARCHAR(100) NOT NULL, - entity_type VARCHAR(50) NOT NULL, - entity_id UUID, - details JSONB DEFAULT '{}', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - -- Valid entity types - CONSTRAINT valid_entity_type CHECK (entity_type IN ('tablo', 'board', 'list', 'card', 'member')) -); - --- 7. TABLO CHAT CHANNELS TABLE --- Chat channels within tablos -CREATE TABLE tablo_chat_channels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tablo_id UUID NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - type VARCHAR(20) NOT NULL DEFAULT 'public', - description TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES auth.users(id), - - -- Valid channel types - CONSTRAINT valid_channel_type CHECK (type IN ('public', 'private', 'direct')) -); - --- 8. TABLO CHAT MESSAGES TABLE --- Messages within chat channels -CREATE TABLE tablo_chat_messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES tablo_chat_channels(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - message_type VARCHAR(20) DEFAULT 'text', - attachments JSONB DEFAULT '[]', - reply_to UUID REFERENCES tablo_chat_messages(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - is_edited BOOLEAN DEFAULT FALSE, - - -- Valid message types - CONSTRAINT valid_message_type CHECK (message_type IN ('text', 'image', 'file', 'system')) -); - --- ===================================================== --- INDEXES FOR PERFORMANCE --- ===================================================== - --- Tablos indexes -CREATE INDEX idx_tablos_owner_id ON tablos(owner_id); -CREATE INDEX idx_tablos_created_at ON tablos(created_at DESC); -CREATE INDEX idx_tablos_name_search ON tablos USING gin(to_tsvector('french', name)); - --- Tablo members indexes -CREATE INDEX idx_tablo_members_tablo_id ON tablo_members(tablo_id); -CREATE INDEX idx_tablo_members_user_id ON tablo_members(user_id); - --- Boards indexes -CREATE INDEX idx_tablo_boards_tablo_id ON tablo_boards(tablo_id); -CREATE INDEX idx_tablo_boards_position ON tablo_boards(tablo_id, position); - --- Lists indexes -CREATE INDEX idx_tablo_lists_board_id ON tablo_lists(board_id); -CREATE INDEX idx_tablo_lists_position ON tablo_lists(board_id, position); - --- Cards indexes -CREATE INDEX idx_tablo_cards_list_id ON tablo_cards(list_id); -CREATE INDEX idx_tablo_cards_position ON tablo_cards(list_id, position); -CREATE INDEX idx_tablo_cards_assignees ON tablo_cards USING gin(assignees); -CREATE INDEX idx_tablo_cards_due_date ON tablo_cards(due_date) WHERE due_date IS NOT NULL; - --- Activities indexes -CREATE INDEX idx_tablo_activities_tablo_id ON tablo_activities(tablo_id); -CREATE INDEX idx_tablo_activities_created_at ON tablo_activities(created_at DESC); -CREATE INDEX idx_tablo_activities_user_id ON tablo_activities(user_id); - --- Chat indexes -CREATE INDEX idx_tablo_chat_channels_tablo_id ON tablo_chat_channels(tablo_id); -CREATE INDEX idx_tablo_chat_messages_channel_id ON tablo_chat_messages(channel_id); -CREATE INDEX idx_tablo_chat_messages_created_at ON tablo_chat_messages(created_at DESC); - --- ===================================================== --- ROW LEVEL SECURITY (RLS) POLICIES --- ===================================================== - --- Enable RLS on all tables -ALTER TABLE tablos ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_members ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_boards ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_lists ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_cards ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_activities ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_chat_channels ENABLE ROW LEVEL SECURITY; -ALTER TABLE tablo_chat_messages ENABLE ROW LEVEL SECURITY; - --- Tablos policies -CREATE POLICY "Users can view tablos they are members of" ON tablos - FOR SELECT USING ( - id IN ( - SELECT tablo_id FROM tablo_members - WHERE user_id = auth.uid() - ) OR owner_id = auth.uid() OR is_public = true - ); - -CREATE POLICY "Users can create their own tablos" ON tablos - FOR INSERT WITH CHECK (owner_id = auth.uid()); - -CREATE POLICY "Owners and admins can update tablos" ON tablos - FOR UPDATE USING ( - owner_id = auth.uid() OR - id IN ( - SELECT tablo_id FROM tablo_members - WHERE user_id = auth.uid() AND role IN ('admin', 'owner') - ) - ); - --- Tablo members policies -CREATE POLICY "Users can view members of tablos they belong to" ON tablo_members - FOR SELECT USING ( - tablo_id IN ( - SELECT tablo_id FROM tablo_members - WHERE user_id = auth.uid() - ) - ); - -CREATE POLICY "Owners and admins can manage members" ON tablo_members - FOR ALL USING ( - tablo_id IN ( - SELECT id FROM tablos WHERE owner_id = auth.uid() - ) OR - tablo_id IN ( - SELECT tablo_id FROM tablo_members - WHERE user_id = auth.uid() AND role IN ('admin', 'owner') - ) - ); - --- ===================================================== --- TRIGGERS FOR AUTOMATIC UPDATES --- ===================================================== - --- Function to update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Apply triggers to relevant tables -CREATE TRIGGER update_tablos_updated_at BEFORE UPDATE ON tablos - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_tablo_boards_updated_at BEFORE UPDATE ON tablo_boards - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_tablo_lists_updated_at BEFORE UPDATE ON tablo_lists - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_tablo_cards_updated_at BEFORE UPDATE ON tablo_cards - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- Function to automatically add owner as member -CREATE OR REPLACE FUNCTION add_owner_as_member() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO tablo_members (tablo_id, user_id, role, permissions) - VALUES ( - NEW.id, - NEW.owner_id, - 'owner', - '{"read": true, "write": true, "admin": true}'::jsonb - ); - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER add_owner_as_member_trigger AFTER INSERT ON tablos - FOR EACH ROW EXECUTE FUNCTION add_owner_as_member(); - --- ===================================================== --- SAMPLE DATA --- ===================================================== - --- Insert sample tablos (assuming user IDs exist) --- Note: Replace with actual user UUIDs from your auth.users table -/* -INSERT INTO tablos (name, description, color, owner_id) VALUES -('Projet Alpha', 'Développement de la nouvelle application', 'bg-blue-500', 'user-uuid-1'), -('Marketing Q4', 'Campagnes marketing pour le quatrième trimestre', 'bg-green-500', 'user-uuid-2'), -('Équipe Dev', 'Coordination de l''équipe de développement', 'bg-purple-500', 'user-uuid-1'), -('Budget 2024', 'Planification budgétaire pour 2024', 'bg-red-500', 'user-uuid-3'), -('Roadmap', 'Feuille de route produit', 'bg-yellow-500', 'user-uuid-1'), -('Support Client', 'Gestion du support client', 'bg-indigo-500', 'user-uuid-2'); -*/ \ No newline at end of file diff --git a/sql/08_create_tablos_table.sql b/sql/08_create_tablos_table.sql index b5417e4..6d3fd76 100644 --- a/sql/08_create_tablos_table.sql +++ b/sql/08_create_tablos_table.sql @@ -1,7 +1,7 @@ -- Create tablos table CREATE TABLE IF NOT EXISTS tablos ( id SERIAL PRIMARY KEY, - user_id UUID NOT NULL, + owner_id UUID NOT NULL, name VARCHAR(255) NOT NULL, image TEXT, color VARCHAR(50), @@ -18,13 +18,18 @@ CREATE TABLE IF NOT EXISTS tablos ( ALTER TABLE tablos ENABLE ROW LEVEL SECURITY; -- Create policy to allow users to see only their own tablos -CREATE POLICY "Users can view own tablos" ON tablos - FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Users can view tablos they have access to" ON tablos + FOR SELECT USING ( + (SELECT auth.uid()) = owner_id + OR EXISTS ( + SELECT 1 FROM tablo_access WHERE tablo_id = tablos.id AND user_id = auth.uid() + ) + ); -- Create policy to allow users to insert their own tablos CREATE POLICY "Users can insert own tablos" ON tablos - FOR INSERT WITH CHECK (auth.uid() = user_id); + FOR INSERT WITH CHECK (auth.uid() = owner_id); -- Create policy to allow users to update their own tablos CREATE POLICY "Users can update own tablos" ON tablos - FOR UPDATE USING (auth.uid() = user_id); + FOR UPDATE USING (auth.uid() = owner_id); diff --git a/sql/09_create_tablo_invitations_table.sql b/sql/09_create_tablo_invitations_table.sql deleted file mode 100644 index 64e553a..0000000 --- a/sql/09_create_tablo_invitations_table.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Create tablo_invitations table -CREATE TABLE IF NOT EXISTS tablo_invitations ( - id SERIAL PRIMARY KEY, - tablo_id INTEGER NOT NULL, - invited_email VARCHAR(255) NOT NULL, - invited_by UUID NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'pending', - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - -- Foreign key constraint to tablos table - CONSTRAINT fk_tablo_invitations_tablo_id - FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE, - - -- Constraint to ensure status is one of the allowed values - CONSTRAINT tablo_invitations_status_check - CHECK (status IN ('pending', 'accepted', 'declined')), - - -- Unique constraint to prevent duplicate invitations - CONSTRAINT unique_tablo_invitation - UNIQUE (tablo_id, invited_email) -); - --- Enable Row Level Security -ALTER TABLE tablo_invitations ENABLE ROW LEVEL SECURITY; - --- Create policy to allow tablo owners to insert invitations -CREATE POLICY "Tablo owners can insert invitations" ON tablo_invitations - FOR INSERT WITH CHECK ( - auth.uid() = invited_by AND - EXISTS ( - SELECT 1 FROM tablos - WHERE tablos.id = tablo_invitations.tablo_id - AND tablos.user_id = auth.uid() - ) - ); - --- Create index for better query performance -CREATE INDEX idx_tablo_invitations_tablo_id ON tablo_invitations(tablo_id); -CREATE INDEX idx_tablo_invitations_invited_email ON tablo_invitations(invited_email); \ No newline at end of file diff --git a/sql/09_create_tablo_invites_table.sql b/sql/09_create_tablo_invites_table.sql new file mode 100644 index 0000000..4e9172b --- /dev/null +++ b/sql/09_create_tablo_invites_table.sql @@ -0,0 +1,21 @@ +-- Create tablo_invitations table +CREATE TABLE IF NOT EXISTS tablo_invites ( + id SERIAL PRIMARY KEY, + tablo_id INTEGER NOT NULL, + invited_email VARCHAR(255) NOT NULL, + invited_by UUID NOT NULL, + invite_token TEXT NOT NULL, + + -- Foreign key constraint to tablos table + CONSTRAINT fk_tablo_invitations_tablo_id + FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE, + + -- Unique constraint to prevent duplicate invitations + CONSTRAINT unique_tablo_invitation + UNIQUE (tablo_id, invited_email) +); + +-- Enable Row Level Security +ALTER TABLE tablo_invites ENABLE ROW LEVEL SECURITY; + +-- No policies for now, since we don't want to provide any access to the table \ No newline at end of file diff --git a/sql/10_create_tablo_access_table.sql b/sql/10_create_tablo_access_table.sql new file mode 100644 index 0000000..18d731c --- /dev/null +++ b/sql/10_create_tablo_access_table.sql @@ -0,0 +1,32 @@ +-- Create tablo_access table for managing shared access to tablos +CREATE TABLE IF NOT EXISTS tablo_access ( + id SERIAL PRIMARY KEY, + tablo_id INTEGER NOT NULL, + user_id UUID NOT NULL, + granted_by UUID NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- Foreign key constraint to tablos table + CONSTRAINT fk_tablo_access_tablo_id + FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE, + + -- Unique constraint to prevent duplicate access records + CONSTRAINT unique_tablo_access + UNIQUE (tablo_id, user_id) +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_tablo_access_tablo_id ON tablo_access(tablo_id); +CREATE INDEX IF NOT EXISTS idx_tablo_access_user_id ON tablo_access(user_id); + +-- Enable Row Level Security +ALTER TABLE tablo_access ENABLE ROW LEVEL SECURITY; + +-- Policy to allow users to view tablo_access records where they are the user +CREATE POLICY "Users can view their tablo access only if the access is active" ON tablo_access + FOR SELECT USING ( + user_id = (SELECT auth.uid()) + AND is_active = TRUE + ); diff --git a/sql/11_create_tablo_access_trigger.sql b/sql/11_create_tablo_access_trigger.sql new file mode 100644 index 0000000..6af50cc --- /dev/null +++ b/sql/11_create_tablo_access_trigger.sql @@ -0,0 +1,34 @@ +-- Create a trigger function that automatically creates tablo_access when a new tablo is created +CREATE OR REPLACE FUNCTION create_tablo_access_for_owner() +RETURNS TRIGGER +SECURITY DEFINER +AS $$ +BEGIN + -- Insert a tablo_access record for the tablo owner + INSERT INTO tablo_access ( + tablo_id, + user_id, + granted_by, + is_active, + is_admin + ) VALUES ( + NEW.id, -- tablo_id: the newly created tablo's id + NEW.owner_id, -- user_id: the tablo owner gets access + NEW.owner_id, -- granted_by: self-granted by the owner + TRUE, -- is_active: access is active + TRUE -- is_admin: owner has admin privileges + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create the trigger that fires after INSERT on tablos table +CREATE TRIGGER trigger_create_tablo_access + AFTER INSERT ON tablos + FOR EACH ROW + EXECUTE FUNCTION create_tablo_access_for_owner(); + +-- Add a comment to document the trigger +COMMENT ON TRIGGER trigger_create_tablo_access ON tablos IS + 'Automatically creates tablo_access record for the tablo owner when a new tablo is created'; \ No newline at end of file diff --git a/ui/src/components/CreateTabloModal.tsx b/ui/src/components/CreateTabloModal.tsx index 8298699..5f3b617 100644 --- a/ui/src/components/CreateTabloModal.tsx +++ b/ui/src/components/CreateTabloModal.tsx @@ -12,7 +12,7 @@ interface CreateTabloModalProps { onCreate: ( tabloData: Omit< Tablo, - "id" | "user_id" | "created_at" | "deleted_at" | "position" + "id" | "owner_id" | "created_at" | "deleted_at" | "position" > ) => void; } diff --git a/ui/src/hooks/tablos.ts b/ui/src/hooks/tablos.ts index 0bcaf72..0e66865 100644 --- a/ui/src/hooks/tablos.ts +++ b/ui/src/hooks/tablos.ts @@ -46,10 +46,10 @@ export const useCreateTablo = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (tablo: Omit) => { + mutationFn: async (tablo: Omit) => { const { error } = await supabase.from("tablos").insert({ ...tablo, - user_id: session?.user.id ?? "", + owner_id: session?.user.id ?? "", }); if (error) throw error; }, diff --git a/ui/src/pages/tablo.tsx b/ui/src/pages/tablo.tsx index 5fce8ba..b477c0a 100644 --- a/ui/src/pages/tablo.tsx +++ b/ui/src/pages/tablo.tsx @@ -45,7 +45,7 @@ export const TabloPage = () => { const createNewTablo = async ( tabloData: Omit< Tablo, - "id" | "user_id" | "created_at" | "deleted_at" | "position" + "id" | "owner_id" | "created_at" | "deleted_at" | "position" > ) => { try { @@ -152,12 +152,12 @@ export const TabloPage = () => { const getUserRole = (tablo: Tablo) => { if (!session?.user) return "Invité"; - return tablo.user_id === session.user.id ? "Admin" : "Invité"; + return tablo.owner_id === session.user.id ? "Admin" : "Invité"; }; const getRoleColor = (tablo: Tablo) => { if (!session?.user) return "text-gray-500 dark:text-gray-400"; - return tablo.user_id === session.user.id + return tablo.owner_id === session.user.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"; }; diff --git a/ui/src/types/database.types.ts b/ui/src/types/database.types.ts index 28a1385..a498a4c 100644 --- a/ui/src/types/database.types.ts +++ b/ui/src/types/database.types.ts @@ -108,29 +108,26 @@ export type Database = { } Relationships: [] } - tablo_invitations: { + tablo_invites: { Row: { - created_at: string | null id: number + invite_token: string invited_by: string invited_email: string - status: string tablo_id: number } Insert: { - created_at?: string | null id?: number + invite_token: string invited_by: string invited_email: string - status?: string tablo_id: number } Update: { - created_at?: string | null id?: number + invite_token?: string invited_by?: string invited_email?: string - status?: string tablo_id?: number } Relationships: [ @@ -151,9 +148,9 @@ export type Database = { id: number image: string | null name: string + owner_id: string position: number status: string - user_id: string } Insert: { color?: string | null @@ -162,9 +159,9 @@ export type Database = { id?: number image?: string | null name: string + owner_id: string position?: number status?: string - user_id: string } Update: { color?: string | null @@ -173,9 +170,9 @@ export type Database = { id?: number image?: string | null name?: string + owner_id?: string position?: number status?: string - user_id?: string } Relationships: [] }