-
Notifications
You must be signed in to change notification settings - Fork 0
added liveblocks live verification , ui changes #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c9ae138
3c2cc7d
5d305b2
175b6fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ node_modules | |
| coverage | ||
| dist | ||
| build | ||
| tsconfig.tsbuildinfo | ||
|
|
||
| /app/generated/prisma | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { auth } from "@clerk/nextjs/server"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { getProjectsForCurrentUser } from "@/lib/projects-data"; | ||
| import { gateRequest } from "@/lib/rate-limit"; | ||
|
|
||
| export async function GET() { | ||
| const { userId } = await auth(); | ||
| if (!userId) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const denied = gateRequest(`invitations:${userId}`, "list"); | ||
| if (denied) return denied; | ||
|
|
||
| const { invitations } = await getProjectsForCurrentUser(); | ||
| return NextResponse.json({ invitations }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { auth } from "@clerk/nextjs/server"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { prisma } from "@/lib/prisma"; | ||
| import type { ClientNotification } from "@/lib/notifications"; | ||
| import { gateRequest } from "@/lib/rate-limit"; | ||
|
|
||
| interface MarkReadBody { | ||
| id?: unknown; | ||
| } | ||
|
|
||
| export async function GET() { | ||
| const { userId } = await auth(); | ||
| if (!userId) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const denied = gateRequest(`notifications:${userId}`, "list"); | ||
| if (denied) return denied; | ||
|
|
||
| const rows = await prisma.projectNotification.findMany({ | ||
| where: { userId }, | ||
| orderBy: [{ readAt: "asc" }, { createdAt: "desc" }], | ||
| take: 50, | ||
| select: { | ||
| id: true, | ||
| projectId: true, | ||
| projectName: true, | ||
| type: true, | ||
| createdAt: true, | ||
| readAt: true, | ||
| }, | ||
| }); | ||
|
|
||
| const notifications: ClientNotification[] = rows.map((row) => ({ | ||
| id: row.id, | ||
| projectId: row.projectId, | ||
| projectName: row.projectName, | ||
| type: row.type as ClientNotification["type"], | ||
| createdAt: row.createdAt.toISOString(), | ||
| read: row.readAt !== null, | ||
| })); | ||
|
|
||
| return NextResponse.json({ notifications }); | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| const { userId } = await auth(); | ||
| if (!userId) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const denied = gateRequest(`notifications:${userId}`, "mutate"); | ||
| if (denied) return denied; | ||
|
|
||
| let body: MarkReadBody; | ||
| try { | ||
| body = (await request.json()) as MarkReadBody; | ||
| } catch { | ||
| return NextResponse.json({ error: "Malformed JSON" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (typeof body.id === "string") { | ||
| await prisma.projectNotification.updateMany({ | ||
| where: { id: body.id, userId }, | ||
| data: { readAt: new Date() }, | ||
| }); | ||
| } else { | ||
| await prisma.projectNotification.updateMany({ | ||
| where: { userId, readAt: null }, | ||
| data: { readAt: new Date() }, | ||
| }); | ||
| } | ||
|
|
||
| return NextResponse.json({ success: true }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,47 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| import { auth } from "@clerk/nextjs/server"; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { NextResponse } from "next/server"; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import { prisma } from "@/lib/prisma"; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { getProjectForAccess } from "@/lib/project-access"; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { gateRequest } from "@/lib/rate-limit"; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| interface RouteContext { | ||||||||||||||||||||||||||||||||||||||||||||||
| params: Promise<{ projectId: string }>; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Lightweight real-time access probe used by the open workspace to reflect an | ||||||||||||||||||||||||||||||||||||||||||||||
| * owner's deletion (or a revoked membership) immediately instead of waiting for | ||||||||||||||||||||||||||||||||||||||||||||||
| * a reload. Distinguishes "deleted" from "denied" by checking row existence. | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| export async function GET(_request: Request, ctx: RouteContext) { | ||||||||||||||||||||||||||||||||||||||||||||||
| const { userId } = await auth(); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!userId) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const denied = gateRequest(`projects:${userId}:access`, "read"); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (denied) return denied; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const { projectId } = await ctx.params; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const result = await getProjectForAccess(projectId); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (result.kind === "unauthenticated") { | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| if (result.kind === "ok") { | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ ok: true }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // The caller lost access — is it because the workspace was deleted, or because | ||||||||||||||||||||||||||||||||||||||||||||||
| // the membership is gone while the project still exists? | ||||||||||||||||||||||||||||||||||||||||||||||
| const exists = await prisma.project.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||||
| where: { id: projectId }, | ||||||||||||||||||||||||||||||||||||||||||||||
| select: { id: true }, | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ | ||||||||||||||||||||||||||||||||||||||||||||||
| ok: false, | ||||||||||||||||||||||||||||||||||||||||||||||
| reason: exists ? "denied" : "deleted", | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+36
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win The response discloses project existence to unrelated users. Any authenticated caller can probe an arbitrary 🔒 Proposed fix- // The caller lost access — is it because the workspace was deleted, or because
- // the membership is gone while the project still exists?
- const exists = await prisma.project.findUnique({
- where: { id: projectId },
- select: { id: true },
- });
-
- return NextResponse.json({
- ok: false,
- reason: exists ? "denied" : "deleted",
- });
+ // Only report "deleted" to a caller that had a prior relationship with the
+ // project; otherwise the endpoint becomes a project-existence oracle.
+ const hadRelationship = await prisma.projectNotification.findFirst({
+ where: { userId, projectId, type: "PROJECT_DELETED" },
+ select: { id: true },
+ });
+
+ return NextResponse.json({
+ ok: false,
+ reason: hadRelationship ? "deleted" : "denied",
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,19 @@ | ||
| import { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { getUserIdByEmail } from "@/lib/collaborators"; | ||
| import { getLiveblocksClient } from "@/lib/liveblocks"; | ||
| import { prisma } from "@/lib/prisma"; | ||
| import { getCurrentIdentity } from "@/lib/project-access"; | ||
| import { gateRequest } from "@/lib/rate-limit"; | ||
|
|
||
| interface RouteContext { | ||
| params: Promise<{ projectId: string; collaboratorId: string }>; | ||
| } | ||
|
|
||
| interface PatchBody { | ||
| canShare?: unknown; | ||
| canEdit?: unknown; | ||
| } | ||
|
|
||
| export async function PATCH(request: Request, ctx: RouteContext) { | ||
|
|
@@ -18,6 +22,9 @@ export async function PATCH(request: Request, ctx: RouteContext) { | |
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const denied = gateRequest(`collaborators:${identity.userId}`, "mutate"); | ||
| if (denied) return denied; | ||
|
|
||
| const { projectId, collaboratorId } = await ctx.params; | ||
|
|
||
| let body: PatchBody; | ||
|
|
@@ -27,12 +34,28 @@ export async function PATCH(request: Request, ctx: RouteContext) { | |
| return NextResponse.json({ error: "Malformed JSON" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (typeof body.canShare !== "boolean") { | ||
| if (body.canShare !== undefined && typeof body.canShare !== "boolean") { | ||
| return NextResponse.json( | ||
| { error: "`canShare` must be a boolean" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
| if (body.canEdit !== undefined && typeof body.canEdit !== "boolean") { | ||
| return NextResponse.json( | ||
| { error: "`canEdit` must be a boolean" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| const data: { canShare?: boolean; canEdit?: boolean } = {}; | ||
| if (typeof body.canShare === "boolean") data.canShare = body.canShare; | ||
| if (typeof body.canEdit === "boolean") data.canEdit = body.canEdit; | ||
| if (Object.keys(data).length === 0) { | ||
| return NextResponse.json( | ||
| { error: "Provide `canShare` and/or `canEdit`" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| const project = await prisma.project.findUnique({ | ||
| where: { id: projectId }, | ||
|
|
@@ -48,9 +71,21 @@ export async function PATCH(request: Request, ctx: RouteContext) { | |
| try { | ||
| const updated = await prisma.projectCollaborator.update({ | ||
| where: { id: collaboratorId }, | ||
| data: { canShare: body.canShare }, | ||
| select: { id: true, email: true, status: true, canShare: true }, | ||
| data, | ||
| select: { | ||
| id: true, | ||
| email: true, | ||
| status: true, | ||
| canShare: true, | ||
| canEdit: true, | ||
| }, | ||
|
Comment on lines
+74
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Scope the collaborator update to Line 70 applies Use a project-scoped lookup or update. Return 🤖 Prompt for AI Agents |
||
| }); | ||
| // Push the new grant into the room now, so a revoked collaborator loses | ||
| // write access on their current connection instead of at token expiry. | ||
| if (typeof data.canEdit === "boolean") { | ||
| await syncRoomAccess(projectId, updated.email, data.canEdit); | ||
| } | ||
|
|
||
| return NextResponse.json({ collaborator: updated }); | ||
| } catch (error) { | ||
| if ( | ||
|
|
@@ -62,3 +97,29 @@ export async function PATCH(request: Request, ctx: RouteContext) { | |
| throw error; | ||
| } | ||
| } | ||
|
|
||
| async function syncRoomAccess( | ||
| projectId: string, | ||
| email: string, | ||
| canEdit: boolean, | ||
| ): Promise<void> { | ||
| try { | ||
| const collaboratorUserId = await getUserIdByEmail(email); | ||
| if (!collaboratorUserId) return; | ||
|
|
||
| const liveblocks = getLiveblocksClient(); | ||
| const room = await liveblocks.getRoom(projectId).catch(() => null); | ||
| if (!room) return; | ||
|
|
||
| await liveblocks.updateRoom(projectId, { | ||
| usersAccesses: { | ||
| [collaboratorUserId]: canEdit | ||
| ? ["room:write"] | ||
| : ["room:read", "room:presence:write"], | ||
| }, | ||
| }); | ||
| } catch { | ||
| // Non-fatal: the database is the source of truth and liveblocks-auth | ||
| // re-derives access on the collaborator's next connection. | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Prisma orderBy nulls first option support PostgreSQL💡 Result:
Yes, Prisma fully supports the
nullsordering option for PostgreSQL, allowing you to explicitly control whethernullvalues appear first or last in your query results [1][2][3]. This feature was made generally available in Prisma version 4.16.0 [3][4]. Usage details: - You can apply this to optional scalar fields in yourorderByclause [2][3]. - If you attempt to use this option on a required or relation field, Prisma will throw a P2009 error [2][3]. - Thenullsargument accepts either"first"or"last"[2][3]. - Note that"first"is the default behavior if thenullsoption is omitted [3]. Example usage: const users = await prisma.user.findMany({ orderBy: { updatedAt: { sort: "asc", nulls: "last" }, }, }); For additional context, this feature is widely supported across most databases compatible with Prisma, though it is notably not supported for MongoDB [3][4]. While there have been reports of inconsistencies in newer SQL query builder APIs regarding this option [5], standard Prisma Client queries handle it as expected [3].Citations:
orderByNullsGA prisma/orm#19377🏁 Script executed:
Repository: EmeditWeb/emedit-AI
Length of output: 10124
🏁 Script executed:
Repository: EmeditWeb/emedit-AI
Length of output: 1267
Set
NULLvalues first inreadAtordering.readAtis nullable. PostgreSQL sortsNULLvalues last for ascending order, so unread notifications can be excluded bytake: 50. Use{ sort: "asc", nulls: "first" }.🤖 Prompt for AI Agents