-
Notifications
You must be signed in to change notification settings - Fork 1
Shared group metadata for invites, adds join requests acceptance tracking #137
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
Closed
Closed
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
8bdd9d4
Prisma schema update
lourou 7dcd40d
Prisma database migration for shared group metadata
lourou 0c6715d
Invite code refactor to handle shared group metadata, and keep the sa…
lourou 3156403
Fix tests
lourou 735e7df
Remove cascade delete on group metadata, clean up metadata when we de…
lourou 20fc6e7
Partial update for group metadata
lourou 86fce65
Null check
lourou 3a52673
Create and update group metadata
lourou 706bb7e
Accept join requests
lourou 3cba177
Atomically claim a slot by incrementing invite uses count with a guard
lourou 70016a1
Only invite creators and users with valid InviteCodeUse can edit meta…
lourou 80a6c6a
Fix invites metadata cleanup
lourou b4c2579
Fix race condition of maxUses
lourou 1efff32
Fix can update check
lourou 70c1bf3
Add xmtpId null check
lourou 6ca201b
Add composite indexes
lourou de4902b
Return success for idempotency - request was already processed
lourou c95dd1f
Idempotency fix
lourou a6a2440
Fixes
lourou 41a04d3
Fixes
lourou File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
25 changes: 25 additions & 0 deletions
25
prisma/migrations/20250919000000_shared_invite_metadata/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| -- CreateTable | ||
| CREATE TABLE "GroupMetadata" ( | ||
| "id" TEXT NOT NULL, | ||
| "name" TEXT, | ||
| "description" TEXT, | ||
| "imageUrl" TEXT, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "updatedAt" TIMESTAMP(3) NOT NULL, | ||
|
|
||
| CONSTRAINT "GroupMetadata_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- Insert empty metadata entries for all existing groupIds | ||
| INSERT INTO "GroupMetadata" ("id", "updatedAt") | ||
| SELECT DISTINCT "groupId", CURRENT_TIMESTAMP | ||
| FROM "InviteCode" | ||
| WHERE "groupId" IS NOT NULL; | ||
|
|
||
| -- Remove old metadata fields from InviteCode | ||
| ALTER TABLE "InviteCode" DROP COLUMN "name"; | ||
| ALTER TABLE "InviteCode" DROP COLUMN "description"; | ||
| ALTER TABLE "InviteCode" DROP COLUMN "imageUrl"; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "InviteCode" ADD CONSTRAINT "InviteCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "GroupMetadata"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
6 changes: 6 additions & 0 deletions
6
prisma/migrations/20250919000002_remove_cascade_from_group_metadata/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| -- Remove CASCADE from the foreign key constraint | ||
| -- First drop the existing constraint | ||
| ALTER TABLE "InviteCode" DROP CONSTRAINT "InviteCode_groupId_fkey"; | ||
|
|
||
| -- Add the constraint back without CASCADE | ||
| ALTER TABLE "InviteCode" ADD CONSTRAINT "InviteCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "GroupMetadata"("id"); |
14 changes: 14 additions & 0 deletions
14
prisma/migrations/20250923131751_add_composite_indexes/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| -- DropForeignKey | ||
| ALTER TABLE "InviteCode" DROP CONSTRAINT "InviteCode_groupId_fkey"; | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "InviteCode_status_expiresAt_idx" ON "InviteCode"("status", "expiresAt"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "InviteCode_createdById_groupId_idx" ON "InviteCode"("createdById", "groupId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "InviteCodeRequest_inviteCodeId_createdAt_idx" ON "InviteCodeRequest"("inviteCodeId", "createdAt"); | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "InviteCode" ADD CONSTRAINT "InviteCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "GroupMetadata"("id") ON DELETE RESTRICT ON UPDATE CASCADE; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| import type { Request, Response } from "express"; | ||
| import { z } from "zod"; | ||
| import { prisma } from "@/utils/prisma"; | ||
|
|
||
| const acceptRequestParams = z.object({ | ||
| requestId: z.string().min(1, "Request ID is required"), | ||
| }); | ||
|
|
||
| export type AcceptRequestToJoinParams = z.infer<typeof acceptRequestParams>; | ||
|
|
||
| export type AcceptRequestToJoinResponse = { | ||
| id: string; | ||
| accepted: boolean; | ||
| inviteCodeUse: { | ||
| id: string; | ||
| usedAt: string; | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Accept a join request and create an InviteCodeUse record | ||
| * Only the invite creator or notification targets can accept requests | ||
| */ | ||
| export async function acceptRequestToJoin(req: Request, res: Response) { | ||
| try { | ||
| const params = await acceptRequestParams.parseAsync(req.params); | ||
| const { xmtpId } = res.locals; | ||
|
|
||
| // Find the authenticated user's identity | ||
| const authenticatedIdentity = await prisma.deviceIdentity.findFirst({ | ||
| where: { xmtpId }, | ||
| }); | ||
|
|
||
| if (!authenticatedIdentity) { | ||
| res.status(404).json({ | ||
| success: false, | ||
| message: "Request not found", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Load the join request with related entities | ||
| const requestToJoin = await prisma.inviteCodeRequest.findUnique({ | ||
| where: { id: params.requestId }, | ||
| include: { | ||
| requester: true, | ||
| inviteCode: { | ||
| include: { | ||
| createdBy: true, | ||
| notificationTargets: { | ||
| include: { | ||
| deviceIdentity: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!requestToJoin) { | ||
| res.status(404).json({ | ||
| success: false, | ||
| message: "Request not found", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Check if user has already been accepted (has InviteCodeUse record) | ||
| const existingUse = await prisma.inviteCodeUse.findUnique({ | ||
| where: { | ||
|
lourou marked this conversation as resolved.
|
||
| inviteCodeId_usedById: { | ||
| inviteCodeId: requestToJoin.inviteCodeId, | ||
| usedById: requestToJoin.requesterId, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (existingUse) { | ||
| // Return success for idempotency - request was already processed | ||
| res.status(200).json({ | ||
| id: params.requestId, | ||
| accepted: true, | ||
| alreadyAccepted: true, | ||
| inviteCodeUse: { | ||
| id: existingUse.id, | ||
| usedAt: existingUse.usedAt.toISOString(), | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Authorization: only invite creator OR notification targets can accept | ||
| const isCreator = requestToJoin.inviteCode.createdBy.xmtpId === xmtpId; | ||
| const isNotificationTarget = | ||
| requestToJoin.inviteCode.notificationTargets.some( | ||
| (target) => target.deviceIdentity.xmtpId === xmtpId, | ||
| ); | ||
|
|
||
| if (!isCreator && !isNotificationTarget) { | ||
| res.status(404).json({ | ||
| success: false, | ||
| message: "Request not found", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Check if invite is still valid | ||
| const inviteCode = requestToJoin.inviteCode; | ||
| if (inviteCode.status !== "ACTIVE") { | ||
| res.status(400).json({ | ||
| success: false, | ||
| message: "Invite is no longer active", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (inviteCode.expiresAt && inviteCode.expiresAt < new Date()) { | ||
| res.status(400).json({ | ||
| success: false, | ||
| message: "Invite has expired", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Accept the request and create InviteCodeUse in a transaction | ||
| const result = await prisma.$transaction(async (tx) => { | ||
| // First fetch the current invite to get maxUses value | ||
| const currentInvite = await tx.inviteCode.findUnique({ | ||
| where: { id: requestToJoin.inviteCodeId }, | ||
| select: { maxUses: true, usesCount: true }, | ||
| }); | ||
|
|
||
| if (!currentInvite) { | ||
| throw new Error("INVITE_NOT_FOUND"); | ||
| } | ||
|
|
||
| // Atomically claim a slot by incrementing uses count with a guard | ||
| const whereConditions: Array< | ||
| { maxUses: null } | { usesCount: { lt: number } } | ||
| > = [{ maxUses: null }]; | ||
|
|
||
| // Only add the usesCount comparison if maxUses is not null | ||
| if (currentInvite.maxUses !== null) { | ||
| whereConditions.push({ usesCount: { lt: currentInvite.maxUses } }); | ||
| } | ||
|
|
||
| const updateResult = await tx.inviteCode.updateMany({ | ||
| where: { | ||
| id: requestToJoin.inviteCodeId, | ||
| OR: whereConditions, | ||
|
lourou marked this conversation as resolved.
Outdated
|
||
| }, | ||
| data: { | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| usesCount: { | ||
| increment: 1, | ||
| }, | ||
| }, | ||
| }); | ||
|
lourou marked this conversation as resolved.
|
||
|
|
||
| // Check if we successfully claimed a slot | ||
| if (updateResult.count === 0) { | ||
| throw new Error("INVITE_MAX_USES_REACHED"); | ||
| } | ||
|
|
||
| // Create InviteCodeUse record | ||
| const inviteCodeUse = await tx.inviteCodeUse.create({ | ||
| data: { | ||
| inviteCodeId: requestToJoin.inviteCodeId, | ||
| usedById: requestToJoin.requesterId, | ||
| }, | ||
| }); | ||
|
|
||
| // Delete the original request (it's been processed) | ||
| await tx.inviteCodeRequest.delete({ | ||
| where: { id: params.requestId }, | ||
| }); | ||
|
|
||
| return { inviteCodeUse }; | ||
| }); | ||
|
|
||
| const response: AcceptRequestToJoinResponse = { | ||
| id: params.requestId, | ||
| accepted: true, | ||
| inviteCodeUse: { | ||
| id: result.inviteCodeUse.id, | ||
| usedAt: result.inviteCodeUse.usedAt.toISOString(), | ||
| }, | ||
| }; | ||
|
|
||
| res.status(200).json(response); | ||
| } catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| res.status(400).json({ | ||
| success: false, | ||
|
lourou marked this conversation as resolved.
|
||
| message: "Invalid request data", | ||
| errors: error.errors, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (error instanceof Error && error.message === "INVITE_MAX_USES_REACHED") { | ||
| res.status(400).json({ | ||
| success: false, | ||
| message: "Invite has reached maximum uses", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (error instanceof Error && error.message === "INVITE_NOT_FOUND") { | ||
| res.status(404).json({ | ||
| success: false, | ||
| message: "Invite not found", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| req.log.error({ error }, "Error accepting request to join"); | ||
| res.status(500).json({ | ||
| success: false, | ||
| message: "Failed to accept request", | ||
| }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.