Merge pull request #27 from artslidd/develop

Develop
This commit is contained in:
Arthur Belleville 2025-10-26 16:19:44 +01:00 committed by GitHub
commit b054d1e7f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 201 additions and 163 deletions

View file

@ -1,6 +1,7 @@
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo } from "./types.ts";
import type { Context, Next } from "hono";
export const generateICSFromEvents = (
events: EventAndTablo[],
@ -119,7 +120,7 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) =>
);
};
export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const { data: tabloAccess, error: isMemberError } = await supabase
.from("tablo_access")
.select("*")
@ -134,7 +135,7 @@ export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, u
return tabloAccess?.length > 0;
};
export const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const { data: tabloAccess, error: isAdminError } = await supabase
.from("tablo_access")
.select("*")
@ -149,3 +150,25 @@ export const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, us
return tabloAccess?.length > 0;
};
export const checkTabloMember = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const isMember = await isTabloMember(supabase, tabloId, user.id);
if (!isMember) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
await next();
};
export const checkTabloAdmin = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const isAdmin = await isTabloAdmin(supabase, tabloId, user.id);
if (!isAdmin) {
return c.json({ error: "You are not an admin of this tablo" }, 403);
}
await next();
};

View file

@ -30,6 +30,14 @@ export const authMiddleware = async (c: Context, next: Next) => {
await next();
};
export const regularUserCheckMiddleware = async (c: Context, next: Next) => {
const user = c.get("user");
if (user.is_temporary) {
return c.json({ error: "User is read only" }, 401);
}
await next();
};
export const supabaseMiddleware = async (c: Context, next: Next) => {
const supabase = createClient(
process.env.SUPABASE_URL as string,

82
api/src/notes.ts Normal file
View file

@ -0,0 +1,82 @@
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import { checkTabloMember } from "./helpers.js";
import type { Database } from "./database.types.js";
import { authMiddleware } from "./middleware.js";
export const notesRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
};
}>();
type Note = Database["public"]["Tables"]["notes"]["Row"];
notesRouter.use(authMiddleware);
/**
* Fetch notes shared with a specific tablo
*/
notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
const { tabloId } = c.req.param();
if (!tabloId) {
return c.json({ error: "Tablo ID is required" }, 400);
}
const supabase = c.get("supabase");
// Find the tablo owner
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("owner_id")
.eq("id", tabloId)
.single();
if (tabloError) {
console.error("Error fetching tablo:", tabloError);
return c.json({ error: "Failed to fetch tablo" }, 500);
}
if (!tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
const tabloOwnerId = tabloData.owner_id;
// Find notes shared with this specific tablo or all tablos
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)
.eq("user_id", tabloOwnerId)
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
.is("notes.deleted_at", null);
if (error) {
return c.json({ error: "An error occurred" }, 500);
}
// Extract notes from the join result and remove duplicates
type JoinedResult = { note_id: string; notes: Note[] };
const extractedNotes = (data as JoinedResult[])
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
.filter((note) => note !== null && note !== undefined);
// Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo)
const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values());
return c.json({ notes: uniqueNotes });
});

View file

@ -4,6 +4,7 @@ import { tabloRouter } from "./tablo.js";
import { tabloDataRouter } from "./tablo_data.js";
import { taskRouter } from "./tasks.js";
import { userRouter } from "./user.js";
import { notesRouter } from "./notes.js";
export const mainRouter = new Hono<{
Bindings: {
@ -34,3 +35,4 @@ mainRouter.route("/users", userRouter);
mainRouter.route("/tablos", tabloRouter);
mainRouter.route("/tasks", taskRouter);
mainRouter.route("/tablo-data", tabloDataRouter);
mainRouter.route("/notes", notesRouter);

View file

@ -6,7 +6,12 @@ import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { writeCalendarFileToR2 } from "./helpers.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import {
authMiddleware,
r2Middleware,
regularUserCheckMiddleware,
streamChatMiddleware,
} from "./middleware.js";
import { generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
@ -41,7 +46,7 @@ type PostTablo = Omit<TabloInsert, "owner_id"> & {
events?: EventInsertInTablo[];
};
tabloRouter.post("/create", async (c) => {
tabloRouter.post("/create", regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
@ -275,7 +280,7 @@ tabloRouter.post("/create-and-invite", async (c) => {
return c.json({ id: tabloData.id });
});
tabloRouter.patch("/update", async (c) => {
tabloRouter.patch("/update", regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
@ -356,7 +361,7 @@ tabloRouter.delete("/delete", async (c) => {
return c.json({ message: "Tablo deleted successfully" });
});
tabloRouter.post("/invite", async (c) => {
tabloRouter.post("/invite", regularUserCheckMiddleware, async (c) => {
const sender = c.get("user");
const supabase = c.get("supabase");
const { email: recipientmail, tablo_id } = await c.req.json();
@ -545,7 +550,7 @@ tabloRouter.post("/leave", async (c) => {
return c.json({ message: "Tablo left successfully" });
});
tabloRouter.post("/webcal/generate-url", async (c) => {
tabloRouter.post("/webcal/generate-url", regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3_client = c.get("s3_client");

View file

@ -1,8 +1,13 @@
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { type Context, Hono, type Next } from "hono";
import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import { Hono } from "hono";
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "./helpers.js";
import {
authMiddleware,
r2Middleware,
regularUserCheckMiddleware,
streamChatMiddleware,
} from "./middleware.js";
export const tabloDataRouter = new Hono<{
Variables: {
@ -16,28 +21,6 @@ tabloDataRouter.use(authMiddleware);
tabloDataRouter.use(streamChatMiddleware);
tabloDataRouter.use(r2Middleware);
const checkTabloMember = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const isMember = await isTabloMember(supabase, tabloId, user.id);
if (!isMember) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
await next();
};
const checkTabloAdmin = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const isAdmin = await isTabloAdmin(supabase, tabloId, user.id);
if (!isAdmin) {
return c.json({ error: "You are not an admin of this tablo" }, 403);
}
await next();
};
// GET /tablo-data/:tabloId/filenames - Get all files for a tablo
tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
@ -88,39 +71,44 @@ tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => {
});
// POST /tablo-data/:tabloId/:fileName - Create or update a file
tabloDataRouter.post("/:tabloId/:fileName", checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
tabloDataRouter.post(
"/:tabloId/:fileName",
regularUserCheckMiddleware,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
const s3_client = c.get("s3_client");
try {
const body = await c.req.json();
const { content, contentType = "text/plain" } = body;
try {
const body = await c.req.json();
const { content, contentType = "text/plain" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Body: content,
ContentType: contentType,
})
);
return c.json({
message: "File uploaded successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Body: content,
ContentType: contentType,
})
);
return c.json({
message: "File uploaded successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
});
);
// // PUT /tablo-data/:tabloId/:fileName - Update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
@ -159,28 +147,33 @@ tabloDataRouter.post("/:tabloId/:fileName", checkTabloAdmin, async (c) => {
// });
// DELETE /tablo-data/:tabloId/:fileName - Delete a file
tabloDataRouter.delete("/:tabloId/:fileName", checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
tabloDataRouter.delete(
"/:tabloId/:fileName",
regularUserCheckMiddleware,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
}
}
});
);

View file

@ -3,6 +3,7 @@ import { Database } from "@xtablo/shared/types/database.types";
import { useNavigate } from "react-router-dom";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
type Note = Database["public"]["Tables"]["notes"]["Row"];
type CreateNoteInput = Pick<Database["public"]["Tables"]["notes"]["Insert"], "title" | "content">;
@ -296,99 +297,23 @@ export function usePublicNote(noteId: string | undefined) {
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 };
}
/**
* Hook to fetch notes shared with a specific tablo
*/
export function useTabloNotes(tabloId: string | undefined) {
const user = useUser();
const { data: notes, isLoading } = useQuery<Note[]>({
const api = useAuthedApi();
const { data, isLoading } = useQuery<{ notes: Note[] }>({
queryKey: ["tablo-notes", tabloId],
queryFn: async () => {
if (!tabloId) return [];
if (!tabloId) return { notes: [] };
// Find notes shared with this specific tablo or all tablos
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)
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
.is("notes.deleted_at", null);
const { data } = await api.get<{ notes: Note[] }>(`/api/v1/notes/${tabloId}`);
if (error) throw error;
// Extract notes from the join result and remove duplicates
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[];
// Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo)
const uniqueNotes = Array.from(
new Map(extractedNotes.map((note) => [note.id, note])).values()
);
return uniqueNotes;
return data;
},
enabled: !!tabloId && !!user.id,
});
return { notes, isLoading };
return { notes: data?.notes, isLoading };
}