Add the ability to share notes

This commit is contained in:
Arthur Belleville 2025-10-26 08:49:45 +01:00
parent a30edae098
commit 39ec616c03
No known key found for this signature in database
12 changed files with 937 additions and 19 deletions

View file

@ -256,6 +256,65 @@ export type Database = {
}
Relationships: []
}
note_access: {
Row: {
created_at: string | null
id: number
is_active: boolean | null
note_id: string
tablo_id: string | null
updated_at: string | null
user_id: string
}
Insert: {
created_at?: string | null
id?: number
is_active?: boolean | null
note_id: string
tablo_id?: string | null
updated_at?: string | null
user_id: string
}
Update: {
created_at?: string | null
id?: number
is_active?: boolean | null
note_id?: string
tablo_id?: string | null
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_note_access_note_id"
columns: ["note_id"]
isOneToOne: false
referencedRelation: "notes"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
notes: {
Row: {
content: string | null
@ -319,6 +378,38 @@ export type Database = {
}
Relationships: []
}
shared_notes: {
Row: {
created_at: string | null
is_public: boolean | null
note_id: string
updated_at: string | null
user_id: string
}
Insert: {
created_at?: string | null
is_public?: boolean | null
note_id: string
updated_at?: string | null
user_id: string
}
Update: {
created_at?: string | null
is_public?: boolean | null
note_id?: string
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_shared_notes_note_id"
columns: ["note_id"]
isOneToOne: true
referencedRelation: "notes"
referencedColumns: ["id"]
},
]
}
tablo_access: {
Row: {
created_at: string | null

View file

@ -7,10 +7,11 @@ import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
interface NotesEditorProps {
initialContent: string;
onChange: (content: string) => void;
onChange?: (content: string) => void;
readOnly?: boolean;
}
export function NotesEditor({ initialContent, onChange }: NotesEditorProps) {
export function NotesEditor({ initialContent, onChange, readOnly = false }: NotesEditorProps) {
const { theme } = useTheme();
// Create editor instance
@ -20,19 +21,19 @@ export function NotesEditor({ initialContent, onChange }: NotesEditorProps) {
// Handle changes
const handleChange = () => {
if (onChange) {
if (onChange && !readOnly) {
const blocks = editor.document;
onChange(JSON.stringify(blocks));
}
};
return (
<div className="h-full w-full">
<div className="w-full p-4">
<BlockNoteView
editor={editor}
theme={theme === "dark" ? "dark" : "light"}
editable
onChange={handleChange}
editable={!readOnly}
onChange={readOnly ? undefined : handleChange}
data-theming-css
/>
</div>

View file

@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { Database } from "@xtablo/shared/types/database.types";
import { useNavigate } from "react-router-dom";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
type Note = Database["public"]["Tables"]["notes"]["Row"];
type CreateNoteInput = Pick<Database["public"]["Tables"]["notes"]["Insert"], "title" | "content">;
@ -127,3 +127,209 @@ export function useDeleteNote() {
});
return { mutate, isPending };
}
/**
* Hook to fetch sharing settings for a specific note
*/
export function useNoteSharing(noteId: string | undefined) {
const user = useUser();
const { data, isLoading } = useQuery({
queryKey: ["note-sharing", noteId],
queryFn: async () => {
if (!noteId) return { isPublic: false, isSharedWithAllTablos: false };
// Fetch public sharing status
const { data: sharedNote, error: sharedError } = await supabase
.from("shared_notes")
.select("is_public")
.eq("note_id", noteId)
.eq("user_id", user.id)
.maybeSingle();
if (sharedError) throw sharedError;
// Fetch tablo sharing status (tablo_id IS NULL means shared with all tablos)
const { data: noteAccess, error: accessError } = await supabase
.from("note_access")
.select("is_active, tablo_id")
.eq("note_id", noteId)
.eq("user_id", user.id)
.is("tablo_id", null)
.maybeSingle();
if (accessError) throw accessError;
return {
isPublic: sharedNote?.is_public ?? false,
isSharedWithAllTablos: noteAccess?.is_active ?? false,
};
},
enabled: !!noteId && !!user.id,
});
return { isPublic: data?.isPublic, isSharedWithAllTablos: data?.isSharedWithAllTablos, isLoading };
}
/**
* Hook to update sharing settings for a note
*/
export function useUpdateNoteSharing() {
const user = useUser();
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{ noteId: string; isPublic: boolean; isSharedWithAllTablos: boolean }
>({
mutationFn: async ({ noteId, isPublic, isSharedWithAllTablos }) => {
// Upsert shared_notes for public sharing
const { error: sharedError } = await supabase.from("shared_notes").upsert(
{
note_id: noteId,
user_id: user.id,
is_public: isPublic,
updated_at: new Date().toISOString(),
},
{
onConflict: "note_id",
}
);
if (sharedError) throw sharedError;
// Handle tablo sharing
if (isSharedWithAllTablos) {
// First, try to update existing "all tablos" sharing record
const { data: existingRecord, error: selectError } = await supabase
.from("note_access")
.select("id")
.eq("note_id", noteId)
.eq("user_id", user.id)
.is("tablo_id", null)
.maybeSingle();
if (selectError) throw selectError;
if (existingRecord) {
// Update existing record
const { error: updateError } = await supabase
.from("note_access")
.update({
is_active: true,
updated_at: new Date().toISOString(),
})
.eq("id", existingRecord.id);
if (updateError) throw updateError;
} else {
// Insert new record
const { error: insertError } = await supabase.from("note_access").insert({
note_id: noteId,
user_id: user.id,
tablo_id: null,
is_active: true,
updated_at: new Date().toISOString(),
});
if (insertError) throw insertError;
}
} else {
// Remove tablo sharing by deleting
const { error: deleteError } = await supabase
.from("note_access")
.delete()
.eq("note_id", noteId)
.eq("user_id", user.id)
.is("tablo_id", null);
if (deleteError) throw deleteError;
}
},
onSuccess: (_, variables) => {
// Invalidate sharing queries
queryClient.invalidateQueries({ queryKey: ["note-sharing", variables.noteId] });
},
});
}
/**
* Hook to fetch a public note (unauthenticated access allowed)
*/
export function usePublicNote(noteId: string | undefined) {
const { data: note, isLoading } = useQuery<Note | null>({
queryKey: ["public-note", noteId],
queryFn: async () => {
if (!noteId) return null;
// First check if the note is public
const { data: sharedNote, error: sharedError } = await supabase
.from("shared_notes")
.select("note_id, is_public")
.eq("note_id", noteId)
.eq("is_public", true)
.maybeSingle();
if (sharedError) throw sharedError;
if (!sharedNote) return null;
// Fetch the actual note
const { data: noteData, error: noteError } = await supabase
.from("notes")
.select("*")
.eq("id", noteId)
.is("deleted_at", null)
.maybeSingle();
if (noteError) throw noteError;
return noteData as Note | null;
},
enabled: !!noteId,
});
return { note, isLoading };
}
/**
* Hook to fetch notes shared with user's tablos
*/
export function useSharedTabloNotes() {
const user = useUser();
const { data: notes, isLoading } = useQuery<Note[]>({
queryKey: ["shared-tablo-notes"],
queryFn: async () => {
// Find notes shared with tablos the user has access to
const { data, error } = await supabase
.from("note_access")
.select(
`
note_id,
notes!inner (
id,
title,
content,
user_id,
created_at,
updated_at,
deleted_at
)
`
)
.eq("is_active", true)
.neq("user_id", user.id) // Don't include user's own notes
.is("notes.deleted_at", null);
if (error) throw error;
// Extract notes from the join result
type JoinedResult = { note_id: string; notes: Note | Note[] };
const extractedNotes = (data as JoinedResult[])
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
.filter((note) => note !== null && note !== undefined) as Note[];
return extractedNotes;
},
enabled: !!user.id,
});
return { notes, isLoading };
}

View file

@ -11,10 +11,11 @@ import { FeedbackPage } from "../pages/feedback";
import { JoinPage } from "../pages/join";
import { LandingPage } from "../pages/landing";
import { LoginPage } from "../pages/login";
import NotesPage from "../pages/notes";
import { NotFoundPage } from "../pages/NotFoundPage";
import NotesPage from "../pages/notes";
import { OAuthSigninPage } from "../pages/oauth-signin";
import { PublicBookingPage } from "../pages/PublicBookingPage";
import { PublicNotePage } from "../pages/PublicNotePage";
import { PlanningPage } from "../pages/planning";
import { ResetPasswordPage } from "../pages/reset-password";
import SettingsPage from "../pages/settings";
@ -135,6 +136,11 @@ export const routes: RouteObject[] = [
path: "/book/:user_info/:event_type_standard_name",
element: <PublicBookingPage />,
},
// Public notes route (unauthenticated access)
{
path: "/notes/public/:noteId",
element: <PublicNotePage />,
},
// Authentication pages (redirected to "/" if user is authenticated)
{
path: "/",

View file

@ -35,5 +35,19 @@
"weeksAgo_other": "{{count}} weeks ago",
"monthsAgo": "{{count}} months ago",
"monthsAgo_one": "{{count}} month ago",
"monthsAgo_other": "{{count}} months ago"
"monthsAgo_other": "{{count}} months ago",
"share": "Share",
"shareNote": "Share Note",
"shareNoteDescription": "Control who can access this note",
"publicAccess": "Public Access",
"publicAccessDescription": "Anyone with the link can view this note",
"shareWithAllTablos": "Share with All Tablos",
"shareWithAllTablosDescription": "Members of all your tablos can view this note",
"close": "Close",
"saveNoteBeforeSharing": "Please save the note before sharing",
"sharingSettingsUpdated": "Sharing settings updated",
"failedToUpdateSharing": "Failed to update sharing",
"publicLinkCopied": "Public link copied to clipboard",
"copyLink": "Copy link",
"openLink": "Open link"
}

View file

@ -35,5 +35,19 @@
"weeksAgo_other": "Il y a {{count}} semaines",
"monthsAgo": "Il y a {{count}} mois",
"monthsAgo_one": "Il y a {{count}} mois",
"monthsAgo_other": "Il y a {{count}} mois"
"monthsAgo_other": "Il y a {{count}} mois",
"share": "Partager",
"shareNote": "Partager la note",
"shareNoteDescription": "Contrôlez qui peut accéder à cette note",
"publicAccess": "Accès public",
"publicAccessDescription": "Toute personne avec le lien peut voir cette note",
"shareWithAllTablos": "Partager avec tous les tablos",
"shareWithAllTablosDescription": "Les membres de tous vos tablos peuvent voir cette note",
"close": "Fermer",
"saveNoteBeforeSharing": "Veuillez enregistrer la note avant de la partager",
"sharingSettingsUpdated": "Paramètres de partage mis à jour",
"failedToUpdateSharing": "Échec de la mise à jour du partage",
"publicLinkCopied": "Lien public copié dans le presse-papiers",
"copyLink": "Copier le lien",
"openLink": "Ouvrir le lien"
}

View file

@ -0,0 +1,68 @@
import { Card, CardContent, CardHeader } from "@xtablo/ui/components/card";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { FileTextIcon, SearchXIcon } from "lucide-react";
import { useParams } from "react-router-dom";
import { NotesEditor } from "../components/NotesEditor";
import { usePublicNote } from "../hooks/notes";
export function PublicNotePage() {
const { noteId } = useParams<{ noteId: string }>();
const { note, isLoading } = usePublicNote(noteId);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<Card className="w-full max-w-4xl mx-4">
<CardContent className="flex items-center justify-center p-8">
<div className="text-center">
<TypographyMuted>Loading note...</TypographyMuted>
</div>
</CardContent>
</Card>
</div>
);
}
if (!note) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<Card className="w-full max-w-4xl mx-4">
<CardContent className="flex flex-col items-center justify-center p-8 gap-4">
<div className="rounded-full bg-muted p-6">
<SearchXIcon className="size-12 text-muted-foreground" />
</div>
<div className="space-y-2 text-center">
<TypographyH3 className="text-lg">Note not found</TypographyH3>
<TypographyMuted className="text-sm max-w-md">
This note doesn't exist, isn't public, or has been deleted.
</TypographyMuted>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex-1 p-6">
<Card className="flex flex-col h-full max-w-5xl mx-auto">
<CardHeader className="border-b flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<FileTextIcon className="size-6 text-primary" />
<div className="flex-1">
<TypographyH3 className="text-2xl font-bold">
{note.title || "Untitled Note"}
</TypographyH3>
<TypographyMuted className="text-sm">Read-only public note</TypographyMuted>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-0">
<NotesEditor initialContent={note.content ?? ""} readOnly={true} />
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -1,24 +1,45 @@
import { toast } from "@xtablo/shared";
import { Button } from "@xtablo/ui/components/button";
import { Card, CardContent, CardHeader } from "@xtablo/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@xtablo/ui/components/dialog";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import { Switch } from "@xtablo/ui/components/switch";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import {
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
CopyIcon,
ExternalLinkIcon,
FileTextIcon,
PlusIcon,
SaveIcon,
SearchXIcon,
Share2Icon,
StickyNoteIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { NotesEditor } from "../components/NotesEditor";
import { useCreateNote, useDeleteNote, useNote, useNotes, useUpdateNote } from "../hooks/notes";
import { useNavigate, useParams } from "react-router-dom";
import { NotesEditor } from "../components/NotesEditor";
import {
useCreateNote,
useDeleteNote,
useNote,
useNoteSharing,
useNotes,
useUpdateNote,
useUpdateNoteSharing,
} from "../hooks/notes";
const useNoteId = () => {
const navigate = useNavigate();
@ -47,6 +68,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
const [editorKey, setEditorKey] = useState(0);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const { notes, isLoading } = useNotes();
const { note, isLoading: isNoteLoading } = useNote(noteId);
@ -54,6 +76,10 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
const { mutate: updateNote, isPending: isUpdating } = useUpdateNote();
const { mutate: deleteNote, isPending: isDeleting } = useDeleteNote();
// Sharing hooks
const { isPublic, isSharedWithAllTablos, isLoading: isSharingLoading } = useNoteSharing(noteId);
const { mutate: updateSharing, isPending: isSharingUpdating } = useUpdateNoteSharing();
useEffect(() => {
if (noteId) {
const note = notes?.find((note) => note.id === noteId);
@ -144,6 +170,52 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
resetEditor();
};
const handleSaveSharing = (isPublic: boolean, isSharedWithAllTablos: boolean) => {
if (!noteId) {
toast.add({
title: t("common:error", "Error"),
description: t("saveNoteBeforeSharing"),
type: "error",
});
return;
}
updateSharing(
{
noteId,
isPublic,
isSharedWithAllTablos,
},
{
onSuccess: () => {
toast.add({
title: t("common:success", "Success"),
description: t("sharingSettingsUpdated"),
type: "success",
});
},
onError: (error: Error) => {
toast.add({
title: t("common:error", "Error"),
description: `${t("failedToUpdateSharing")}: ${error.message}`,
type: "error",
});
},
}
);
};
const handleCopyPublicLink = () => {
if (!noteId) return;
const publicUrl = `${window.location.origin}/notes/public/${noteId}`;
navigator.clipboard.writeText(publicUrl);
toast.add({
title: t("common:success", "Success"),
description: t("publicLinkCopied"),
type: "success",
});
};
const renderNote = () => {
if (mode === "edit" && noteId) {
if (isNoteLoading) {
@ -213,7 +285,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
)}
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<CardContent className="flex-1 overflow-y-auto p-0">
<NotesEditor
key={editorKey}
initialContent={note?.content ?? content}
@ -235,10 +307,110 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
<TypographyMuted>{t("description")}</TypographyMuted>
</div>
</div>
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isCreating || isUpdating}>
<SaveIcon className="mr-2 size-4" />
{isCreating ? t("saving") : t("saveNote")}
</Button>
<div className="flex items-center gap-2">
{noteId && (
<Dialog open={isShareDialogOpen} onOpenChange={setIsShareDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Share2Icon className="mr-2 size-4" />
{t("share")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("shareNote")}</DialogTitle>
<DialogDescription>{t("shareNoteDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Public Sharing */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="public-toggle" className="text-base font-medium">
{t("publicAccess")}
</Label>
<TypographyMuted className="text-sm">
{t("publicAccessDescription")}
</TypographyMuted>
</div>
<Switch
id="public-toggle"
checked={isPublic}
onCheckedChange={(checked) =>
handleSaveSharing(checked, isSharedWithAllTablos)
}
disabled={isSharingLoading || isSharingUpdating}
/>
</div>
{isPublic && (
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
<Input
readOnly
value={`${window.location.origin}/notes/public/${noteId}`}
className="text-xs"
/>
<Button
size="icon"
variant="ghost"
onClick={handleCopyPublicLink}
title={t("copyLink")}
>
<CopyIcon className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() =>
window.open(
`${window.location.origin}/notes/public/${noteId}`,
"_blank"
)
}
title={t("openLink")}
>
<ExternalLinkIcon className="size-4" />
</Button>
</div>
)}
</div>
{/* Tablo Sharing */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="tablo-toggle" className="text-base font-medium">
{t("shareWithAllTablos")}
</Label>
<TypographyMuted className="text-sm">
{t("shareWithAllTablosDescription")}
</TypographyMuted>
</div>
<Switch
id="tablo-toggle"
checked={isSharedWithAllTablos}
onCheckedChange={(checked) => handleSaveSharing(isPublic, checked)}
disabled={isSharingLoading || isSharingUpdating}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setIsShareDialogOpen(false);
}}
>
{t("close")}
</Button>
</div>
</DialogContent>
</Dialog>
)}
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isCreating || isUpdating}>
<SaveIcon className="mr-2 size-4" />
{isCreating ? t("saving") : t("saveNote")}
</Button>
</div>
</div>
{/* Main Content */}

File diff suppressed because one or more lines are too long

View file

@ -256,6 +256,65 @@ export type Database = {
}
Relationships: []
}
note_access: {
Row: {
created_at: string | null
id: number
is_active: boolean | null
note_id: string
tablo_id: string | null
updated_at: string | null
user_id: string
}
Insert: {
created_at?: string | null
id?: number
is_active?: boolean | null
note_id: string
tablo_id?: string | null
updated_at?: string | null
user_id: string
}
Update: {
created_at?: string | null
id?: number
is_active?: boolean | null
note_id?: string
tablo_id?: string | null
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_note_access_note_id"
columns: ["note_id"]
isOneToOne: false
referencedRelation: "notes"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
notes: {
Row: {
content: string | null
@ -319,6 +378,38 @@ export type Database = {
}
Relationships: []
}
shared_notes: {
Row: {
created_at: string | null
is_public: boolean | null
note_id: string
updated_at: string | null
user_id: string
}
Insert: {
created_at?: string | null
is_public?: boolean | null
note_id: string
updated_at?: string | null
user_id: string
}
Update: {
created_at?: string | null
is_public?: boolean | null
note_id?: string
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_shared_notes_note_id"
columns: ["note_id"]
isOneToOne: true
referencedRelation: "notes"
referencedColumns: ["id"]
},
]
}
tablo_access: {
Row: {
created_at: string | null

View file

@ -0,0 +1,164 @@
-- Create shared_notes table for public sharing functionality
CREATE TABLE IF NOT EXISTS shared_notes (
note_id TEXT PRIMARY KEY,
user_id UUID NOT NULL,
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Foreign key constraint to notes table
CONSTRAINT fk_shared_notes_note_id
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_shared_notes_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE
);
-- Create index for performance on public notes
CREATE INDEX IF NOT EXISTS idx_shared_notes_is_public ON shared_notes(is_public);
CREATE INDEX IF NOT EXISTS idx_shared_notes_user_id ON shared_notes(user_id);
-- Enable Row Level Security
ALTER TABLE shared_notes ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own shared_notes entries
CREATE POLICY "Users can view their own shared notes" ON shared_notes
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Policy to allow anonymous users to view public notes
CREATE POLICY "Anyone can view public notes" ON shared_notes
FOR SELECT
TO anon
USING (is_public = TRUE);
-- Policy to allow users to insert their own shared_notes entries
CREATE POLICY "Users can insert their own shared notes" ON shared_notes
FOR INSERT
TO authenticated
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to update their own shared_notes entries
CREATE POLICY "Users can update their own shared notes" ON shared_notes
FOR UPDATE
TO authenticated
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to delete their own shared_notes entries
CREATE POLICY "Users can delete their own shared notes" ON shared_notes
FOR DELETE
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Create note_access table for tablo-based sharing
CREATE TABLE IF NOT EXISTS note_access (
id SERIAL PRIMARY KEY,
note_id TEXT NOT NULL,
user_id UUID NOT NULL,
tablo_id TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Foreign key constraint to notes table
CONSTRAINT fk_note_access_note_id
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_note_access_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE,
-- Foreign key constraint to tablos table (nullable for "all tablos" sharing)
CONSTRAINT fk_note_access_tablo_id
FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_note_access_note_id ON note_access(note_id);
CREATE INDEX IF NOT EXISTS idx_note_access_user_id ON note_access(user_id);
CREATE INDEX IF NOT EXISTS idx_note_access_tablo_id ON note_access(tablo_id);
CREATE INDEX IF NOT EXISTS idx_note_access_is_active ON note_access(is_active);
-- Unique constraint for specific tablo sharing (when tablo_id IS NOT NULL)
CREATE UNIQUE INDEX IF NOT EXISTS unique_note_access_with_tablo
ON note_access(note_id, user_id, tablo_id)
WHERE tablo_id IS NOT NULL;
-- Unique constraint for "all tablos" sharing (when tablo_id IS NULL)
-- Only one NULL tablo_id per (note_id, user_id) combination
CREATE UNIQUE INDEX IF NOT EXISTS unique_note_access_all_tablos
ON note_access(note_id, user_id)
WHERE tablo_id IS NULL;
-- Enable Row Level Security
ALTER TABLE note_access ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own note_access entries
CREATE POLICY "Users can view their own note access" ON note_access
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Policy to allow users to view notes shared with their tablos
CREATE POLICY "Users can view notes shared with their tablos" ON note_access
FOR SELECT
TO authenticated
USING (
is_active = TRUE
AND (
-- Shared with all tablos (tablo_id is NULL)
tablo_id IS NULL
-- Or shared with a specific tablo where the user has access
OR EXISTS (
SELECT 1 FROM tablo_access
WHERE tablo_access.tablo_id = note_access.tablo_id
AND tablo_access.user_id = (SELECT auth.uid())
AND tablo_access.is_active = TRUE
)
)
);
-- Policy to allow users to insert their own note_access entries
CREATE POLICY "Users can insert their own note access" ON note_access
FOR INSERT
TO authenticated
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to update their own note_access entries
CREATE POLICY "Users can update their own note access" ON note_access
FOR UPDATE
TO authenticated
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to delete their own note_access entries
CREATE POLICY "Users can delete their own note access" ON note_access
FOR DELETE
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Add comments to document the tables
COMMENT ON TABLE shared_notes IS
'Tracks which notes are shared publicly with Row Level Security';
COMMENT ON COLUMN shared_notes.note_id IS
'Foreign key reference to notes.id';
COMMENT ON COLUMN shared_notes.user_id IS
'Foreign key reference to auth.users.id - owner of the note';
COMMENT ON COLUMN shared_notes.is_public IS
'When TRUE, the note is publicly accessible via /notes/public/:noteId';
COMMENT ON TABLE note_access IS
'Tracks which notes are shared with tablos. When tablo_id IS NULL and is_active = TRUE, the note is shared with all user tablos. Uses partial unique indexes to handle NULL values correctly.';
COMMENT ON COLUMN note_access.tablo_id IS
'Foreign key reference to tablos.id - NULL means shared with all user tablos. Partial unique indexes ensure only one NULL per (note_id, user_id) combination.';
COMMENT ON COLUMN note_access.is_active IS
'When TRUE, the sharing is active';

View file

@ -256,6 +256,65 @@ export type Database = {
}
Relationships: []
}
note_access: {
Row: {
created_at: string | null
id: number
is_active: boolean | null
note_id: string
tablo_id: string | null
updated_at: string | null
user_id: string
}
Insert: {
created_at?: string | null
id?: number
is_active?: boolean | null
note_id: string
tablo_id?: string | null
updated_at?: string | null
user_id: string
}
Update: {
created_at?: string | null
id?: number
is_active?: boolean | null
note_id?: string
tablo_id?: string | null
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_note_access_note_id"
columns: ["note_id"]
isOneToOne: false
referencedRelation: "notes"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "fk_note_access_tablo_id"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
notes: {
Row: {
content: string | null
@ -319,6 +378,38 @@ export type Database = {
}
Relationships: []
}
shared_notes: {
Row: {
created_at: string | null
is_public: boolean | null
note_id: string
updated_at: string | null
user_id: string
}
Insert: {
created_at?: string | null
is_public?: boolean | null
note_id: string
updated_at?: string | null
user_id: string
}
Update: {
created_at?: string | null
is_public?: boolean | null
note_id?: string
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "fk_shared_notes_note_id"
columns: ["note_id"]
isOneToOne: true
referencedRelation: "notes"
referencedColumns: ["id"]
},
]
}
tablo_access: {
Row: {
created_at: string | null