diff --git a/api/src/helpers.ts b/api/src/helpers.ts index 1a52591..f852917 100644 --- a/api/src/helpers.ts +++ b/api/src/helpers.ts @@ -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(); +}; diff --git a/api/src/middleware.ts b/api/src/middleware.ts index 8b192fb..7210da3 100644 --- a/api/src/middleware.ts +++ b/api/src/middleware.ts @@ -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, diff --git a/api/src/notes.ts b/api/src/notes.ts new file mode 100644 index 0000000..0d23e82 --- /dev/null +++ b/api/src/notes.ts @@ -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 }); +}); diff --git a/api/src/routers.ts b/api/src/routers.ts index e2738ad..401f3b8 100644 --- a/api/src/routers.ts +++ b/api/src/routers.ts @@ -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); diff --git a/api/src/tablo.ts b/api/src/tablo.ts index 5640f17..1e2a159 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -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 & { 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"); diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts index 22b25cd..a841f25 100644 --- a/api/src/tablo_data.ts +++ b/api/src/tablo_data.ts @@ -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); + } } -}); +);