diff --git a/prisma/migrations/20250919000000_shared_invite_metadata/migration.sql b/prisma/migrations/20250919000000_shared_invite_metadata/migration.sql new file mode 100644 index 00000000..b1e2d836 --- /dev/null +++ b/prisma/migrations/20250919000000_shared_invite_metadata/migration.sql @@ -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; diff --git a/prisma/migrations/20250919000002_remove_cascade_from_group_metadata/migration.sql b/prisma/migrations/20250919000002_remove_cascade_from_group_metadata/migration.sql new file mode 100644 index 00000000..373968a3 --- /dev/null +++ b/prisma/migrations/20250919000002_remove_cascade_from_group_metadata/migration.sql @@ -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"); diff --git a/prisma/migrations/20250923131751_add_composite_indexes/migration.sql b/prisma/migrations/20250923131751_add_composite_indexes/migration.sql new file mode 100644 index 00000000..29617499 --- /dev/null +++ b/prisma/migrations/20250923131751_add_composite_indexes/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba69367d..aebd46bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,11 +108,20 @@ enum InviteCodeRequestStatus { REJECTED } -model InviteCode { - id String @id @default(cuid()) +model GroupMetadata { + id String @id // This is the groupId name String? description String? imageUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + inviteCodes InviteCode[] +} + +model InviteCode { + id String @id @default(cuid()) maxUses Int? // null = unlimited uses usesCount Int @default(0) status InviteCodeStatus @default(ACTIVE) @@ -122,6 +131,9 @@ model InviteCode { // Group ID to identify which group this invite code is for groupId String + // Shared metadata reference + groupMetadata GroupMetadata @relation(fields: [groupId], references: [id]) + // Keep track of who created it for admin purposes createdById String createdBy DeviceIdentity @relation(fields: [createdById], references: [id], onDelete: Cascade) @@ -135,6 +147,8 @@ model InviteCode { @@index([status]) @@index([createdById]) @@index([groupId]) + @@index([status, expiresAt]) + @@index([createdById, groupId]) } model InviteCodeUse { @@ -175,4 +189,5 @@ model InviteCodeRequest { @@unique([inviteCodeId, requesterId]) @@index([inviteCodeId]) @@index([requesterId]) + @@index([inviteCodeId, createdAt]) } diff --git a/src/api/v1/invites/handlers/accept-request-to-join.ts b/src/api/v1/invites/handlers/accept-request-to-join.ts new file mode 100644 index 00000000..64bb4016 --- /dev/null +++ b/src/api/v1/invites/handlers/accept-request-to-join.ts @@ -0,0 +1,223 @@ +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; + +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 and check authorization in one query + const requestToJoin = await prisma.inviteCodeRequest.findFirst({ + where: { + id: params.requestId, + OR: [ + // User is the invite creator + { inviteCode: { createdBy: { xmtpId } } }, + // User is a notification target + { + inviteCode: { + notificationTargets: { + some: { deviceIdentity: { xmtpId } }, + }, + }, + }, + ], + }, + include: { + requester: true, + inviteCode: { + include: { + createdBy: true, + notificationTargets: { + include: { + deviceIdentity: true, + }, + }, + }, + }, + }, + }); + + // Single 404 response for both "not found" and "not authorized" + if (!requestToJoin) { + res.status(404).json({ + success: false, + message: "Request not found", + }); + return; + } + + // Check if user has already been accepted (has InviteCodeUse record) + // This check is done AFTER authorization to prevent information disclosure + const existingUse = await prisma.inviteCodeUse.findUnique({ + where: { + inviteCodeId_usedById: { + inviteCodeId: requestToJoin.inviteCodeId, + usedById: requestToJoin.requesterId, + }, + }, + }); + + if (existingUse) { + // User already accepted - this should not happen with proper client flow + res.status(409).json({ + success: false, + message: "Request has already been processed", + }); + 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, + AND: [ + { status: "ACTIVE" }, + { OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] }, + { OR: whereConditions }, + ], + }, + data: { + usesCount: { + increment: 1, + }, + }, + }); + + // Check if we successfully claimed a slot + if (updateResult.count === 0) { + throw new Error("INVITE_NOT_AVAILABLE"); + } + + // 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, + message: "Invalid request data", + errors: error.errors, + }); + return; + } + + if (error instanceof Error && error.message === "INVITE_NOT_AVAILABLE") { + res.status(400).json({ + success: false, + message: + "Invite is no longer available (may be expired, inactive, or at capacity)", + }); + 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", + }); + } +} diff --git a/src/api/v1/invites/handlers/create-invite-code.ts b/src/api/v1/invites/handlers/create-invite-code.ts index 12b3c3f5..7ad980c6 100644 --- a/src/api/v1/invites/handlers/create-invite-code.ts +++ b/src/api/v1/invites/handlers/create-invite-code.ts @@ -58,6 +58,14 @@ export async function createInviteCode( // Get the authenticated user's identity from the JWT const { xmtpId } = res.locals; + if (!xmtpId) { + res.status(401).json({ + success: false, + message: "Authentication required", + }); + return; + } + const identity = await prisma.deviceIdentity.findFirst({ where: { xmtpId }, }); @@ -101,29 +109,49 @@ export async function createInviteCode( } // Create the invite code with notification targets - const inviteCode = await prisma.inviteCode.create({ - data: { - name: body.name, - description: body.description, - imageUrl: body.imageUrl, - maxUses: body.maxUses, - expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, - autoApprove: body.autoApprove, - groupId: body.groupId, - createdById: identity.id, - notificationTargets: { - create: notificationTargetIds.map((deviceIdentityId) => ({ - deviceIdentityId, - })), + const inviteCode = await prisma.$transaction(async (tx) => { + // Only create group metadata if it doesn't exist (don't overwrite existing) + const existingMetadata = await tx.groupMetadata.findUnique({ + where: { id: body.groupId }, + }); + + if (!existingMetadata) { + await tx.groupMetadata.create({ + data: { + id: body.groupId, + name: body.name, + description: body.description, + imageUrl: body.imageUrl, + }, + }); + } + // If metadata exists, leave it untouched (don't overwrite other users' group data) + + // Create the invite code + return await tx.inviteCode.create({ + data: { + maxUses: body.maxUses, + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, + autoApprove: body.autoApprove, + groupId: body.groupId, + createdById: identity.id, + notificationTargets: { + create: notificationTargetIds.map((deviceIdentityId) => ({ + deviceIdentityId, + })), + }, + }, + include: { + groupMetadata: true, }, - }, + }); }); const response: CreateInviteCodeResponse = { - id: inviteCode.id, // This cuid serves as the invite code for /join/INVITECODE - name: inviteCode.name, - description: inviteCode.description, - imageUrl: inviteCode.imageUrl, + id: inviteCode.id, + name: inviteCode.groupMetadata.name ?? null, + description: inviteCode.groupMetadata.description ?? null, + imageUrl: inviteCode.groupMetadata.imageUrl ?? null, maxUses: inviteCode.maxUses, usesCount: inviteCode.usesCount, status: inviteCode.status, diff --git a/src/api/v1/invites/handlers/delete-invite.ts b/src/api/v1/invites/handlers/delete-invite.ts index ae883ab4..4c8f7032 100644 --- a/src/api/v1/invites/handlers/delete-invite.ts +++ b/src/api/v1/invites/handlers/delete-invite.ts @@ -53,7 +53,20 @@ export async function deleteInvite(req: Request, res: Response) { return; } - await prisma.inviteCode.delete({ where: { id: params.inviteId } }); + // Delete the invite and clean up orphaned group metadata + await prisma.$transaction(async (tx) => { + await tx.inviteCode.delete({ where: { id: params.inviteId } }); + + const remainingInvites = await tx.inviteCode.count({ + where: { groupId: invite.groupId }, + }); + + if (remainingInvites === 0) { + await tx.groupMetadata.deleteMany({ + where: { id: invite.groupId }, + }); + } + }); const response: DeleteInviteResponse = { id: params.inviteId, diff --git a/src/api/v1/invites/handlers/get-invite-details.ts b/src/api/v1/invites/handlers/get-invite-details.ts index 6c75d9fc..f39f5ed8 100644 --- a/src/api/v1/invites/handlers/get-invite-details.ts +++ b/src/api/v1/invites/handlers/get-invite-details.ts @@ -56,10 +56,14 @@ export const getPublicInviteDetailsHandler = async ( }, select: { id: true, - name: true, - description: true, - imageUrl: true, groupId: true, + groupMetadata: { + select: { + name: true, + description: true, + imageUrl: true, + }, + }, }, }); @@ -73,9 +77,9 @@ export const getPublicInviteDetailsHandler = async ( const response: GetPublicInviteDetailsResponse = { id: invite.id, - name: invite.name, - description: invite.description, - imageUrl: invite.imageUrl, + name: invite.groupMetadata.name ?? null, + description: invite.groupMetadata.description ?? null, + imageUrl: invite.groupMetadata.imageUrl ?? null, inviteLinkURL: getInviteLink(invite.id), }; @@ -122,6 +126,9 @@ export const getOwnerInviteDetailsHandler = async ( const invite = await prisma.inviteCode.findUnique({ where: { id: inviteId }, + include: { + groupMetadata: true, + }, }); if (!invite) { @@ -143,9 +150,9 @@ export const getOwnerInviteDetailsHandler = async ( const response: GetInviteDetailsResponse = { id: invite.id, - name: invite.name, - description: invite.description, - imageUrl: invite.imageUrl, + name: invite.groupMetadata.name ?? null, + description: invite.groupMetadata.description ?? null, + imageUrl: invite.groupMetadata.imageUrl ?? null, maxUses: invite.maxUses, usesCount: invite.usesCount, status: invite.status, @@ -213,10 +220,14 @@ export const getAuthenticatedInviteDetailsHandler = async ( }, select: { id: true, - name: true, - description: true, - imageUrl: true, groupId: true, + groupMetadata: { + select: { + name: true, + description: true, + imageUrl: true, + }, + }, createdBy: { select: { xmtpId: true, @@ -235,9 +246,9 @@ export const getAuthenticatedInviteDetailsHandler = async ( const response: GetAuthenticatedInviteDetailsResponse = { id: invite.id, - name: invite.name, - description: invite.description, - imageUrl: invite.imageUrl, + name: invite.groupMetadata.name ?? null, + description: invite.groupMetadata.description ?? null, + imageUrl: invite.groupMetadata.imageUrl ?? null, inviteLinkURL: getInviteLink(invite.id), groupId: invite.groupId, inviterInboxId: invite.createdBy.xmtpId, diff --git a/src/api/v1/invites/handlers/get-invite-requests.ts b/src/api/v1/invites/handlers/get-invite-requests.ts index 5fc53d78..6a43dbf8 100644 --- a/src/api/v1/invites/handlers/get-invite-requests.ts +++ b/src/api/v1/invites/handlers/get-invite-requests.ts @@ -76,9 +76,13 @@ export async function getInviteRequests( inviteCode: { select: { id: true, - name: true, - description: true, groupId: true, + groupMetadata: { + select: { + name: true, + description: true, + }, + }, }, }, }, @@ -106,8 +110,8 @@ export async function getInviteRequests( }, inviteCode: { id: request.inviteCode.id, - name: request.inviteCode.name, - description: request.inviteCode.description, + name: request.inviteCode.groupMetadata.name ?? null, + description: request.inviteCode.groupMetadata.description ?? null, groupId: request.inviteCode.groupId, }, })), diff --git a/src/api/v1/invites/handlers/request-to-join.ts b/src/api/v1/invites/handlers/request-to-join.ts index b2983641..5875d728 100644 --- a/src/api/v1/invites/handlers/request-to-join.ts +++ b/src/api/v1/invites/handlers/request-to-join.ts @@ -1,4 +1,3 @@ -import type { InviteCode } from "@prisma/client"; import type { Request, Response } from "express"; import { z } from "zod"; import { getInviteLink } from "@/utils/invites"; @@ -14,7 +13,20 @@ export type RequestToJoinRequestBody = z.infer; export type RequestToJoinResponse = { id: string; - invite: InviteCode & { + invite: { + id: string; + name: string | null; + description: string | null; + imageUrl: string | null; + maxUses: number | null; + usesCount: number; + status: string; + expiresAt: string | null; + autoApprove: boolean; + groupId: string; + createdAt: string; + updatedAt: string; + createdById: string; inviteLinkURL: string; }; createdAt: string; @@ -106,10 +118,22 @@ export async function requestToJoin( inviteCode: { select: { id: true, - name: true, - description: true, - groupId: true, + maxUses: true, + usesCount: true, + status: true, + expiresAt: true, autoApprove: true, + groupId: true, + createdAt: true, + updatedAt: true, + createdById: true, + groupMetadata: { + select: { + name: true, + description: true, + imageUrl: true, + }, + }, createdBy: { select: { xmtpId: true, @@ -143,8 +167,8 @@ export async function requestToJoin( }, inviteCode: { id: requestToJoin.inviteCode.id, - name: requestToJoin.inviteCode.name, - description: requestToJoin.inviteCode.description, + name: requestToJoin.inviteCode.groupMetadata.name ?? null, + description: requestToJoin.inviteCode.groupMetadata.description ?? null, groupId: requestToJoin.inviteCode.groupId, }, autoApprove: requestToJoin.inviteCode.autoApprove, @@ -182,8 +206,22 @@ export async function requestToJoin( const response: RequestToJoinResponse = { id: requestToJoin.id, invite: { - ...inviteCode, - inviteLinkURL: getInviteLink(inviteCode.id), + id: requestToJoin.inviteCode.id, + name: requestToJoin.inviteCode.groupMetadata.name ?? null, + description: requestToJoin.inviteCode.groupMetadata.description ?? null, + imageUrl: requestToJoin.inviteCode.groupMetadata.imageUrl ?? null, + maxUses: requestToJoin.inviteCode.maxUses, + usesCount: requestToJoin.inviteCode.usesCount, + status: requestToJoin.inviteCode.status, + expiresAt: requestToJoin.inviteCode.expiresAt + ? requestToJoin.inviteCode.expiresAt.toISOString() + : null, + autoApprove: requestToJoin.inviteCode.autoApprove, + groupId: requestToJoin.inviteCode.groupId, + createdAt: requestToJoin.inviteCode.createdAt.toISOString(), + updatedAt: requestToJoin.inviteCode.updatedAt.toISOString(), + createdById: requestToJoin.inviteCode.createdById, + inviteLinkURL: getInviteLink(requestToJoin.inviteCode.id), }, createdAt: requestToJoin.createdAt.toISOString(), }; diff --git a/src/api/v1/invites/handlers/update-invite-code.ts b/src/api/v1/invites/handlers/update-invite-code.ts index 737d2b5a..68e63f74 100644 --- a/src/api/v1/invites/handlers/update-invite-code.ts +++ b/src/api/v1/invites/handlers/update-invite-code.ts @@ -1,23 +1,58 @@ -import type { InviteCodeStatus } from "@prisma/client"; +import type { InviteCodeStatus, Prisma } from "@prisma/client"; import type { Request, Response } from "express"; import { z } from "zod"; import { getInviteLink } from "@/utils/invites"; import { prisma } from "@/utils/prisma"; import { InviteCodeStatusSchema } from "../../../../../prisma/generated/zod"; +/** + * Checks if a user can update group metadata. + * Allows both the creator of the specific invite being updated and users with valid InviteCodeUse + */ +async function checkCanUpdateGroupMetadata(args: { + tx: Prisma.TransactionClient; + identityId: string; + groupId: string; + inviteId: string; +}) { + const { tx, identityId, groupId, inviteId } = args; + + const hasCreatedThisInvite = await tx.inviteCode.findFirst({ + where: { + id: inviteId, + groupId, + createdById: identityId, + }, + }); + + if (hasCreatedThisInvite) { + return true; + } + + const hasValidUse = await tx.inviteCodeUse.findFirst({ + where: { + usedById: identityId, + inviteCode: { + groupId: groupId, + }, + }, + }); + + return !!hasValidUse; +} + const paramsSchema = z.object({ inviteId: z.string().min(1, "Invite ID is required"), }); export const updateInviteCodeRequestBodySchema = z.object({ - groupId: z.string(), name: z.string().optional(), description: z.string().optional(), imageUrl: z.string().url().optional(), maxUses: z.number().int().positive().optional(), expiresAt: z.string().datetime().optional(), - autoApprove: z.boolean().default(false), - notificationTargets: z.array(z.string()).default([]), + autoApprove: z.boolean().optional(), + notificationTargets: z.array(z.string()).optional(), status: InviteCodeStatusSchema.optional(), }); @@ -68,6 +103,14 @@ export async function updateInviteCode( // Get the authenticated user's identity from the JWT const { xmtpId } = res.locals; + if (!xmtpId) { + res.status(401).json({ + success: false, + message: "Authentication required", + }); + return; + } + const identity = await prisma.deviceIdentity.findFirst({ where: { xmtpId }, }); @@ -93,7 +136,14 @@ export async function updateInviteCode( return; } - if (existingInvite.createdById !== identity.id) { + const canUpdate = await checkCanUpdateGroupMetadata({ + tx: prisma, + identityId: identity.id, + groupId: existingInvite.groupId, + inviteId: params.inviteId, + }); + + if (!canUpdate) { res.status(403).json({ success: false, message: "Not authorized to update this invite", @@ -103,7 +153,7 @@ export async function updateInviteCode( // Validate notification targets exist if provided let notificationTargetIds: string[] = []; - if (body.notificationTargets.length > 0) { + if (body.notificationTargets && body.notificationTargets.length > 0) { const targetIdentities = await prisma.deviceIdentity.findMany({ where: { xmtpId: { @@ -133,28 +183,80 @@ export async function updateInviteCode( // Update the invite code const inviteCode = await prisma.$transaction(async (tx) => { - // Delete existing notification targets - await tx.inviteCodeNotificationTarget.deleteMany({ - where: { inviteCodeId: params.inviteId }, + // Delete existing notification targets if new ones are provided + if (body.notificationTargets !== undefined) { + await tx.inviteCodeNotificationTarget.deleteMany({ + where: { inviteCodeId: params.inviteId }, + }); + } + + // Check authorization for metadata updates + const canUpdateMetadata = await checkCanUpdateGroupMetadata({ + tx, + identityId: identity.id, + groupId: existingInvite.groupId, // Use groupId from existing invite + inviteId: params.inviteId, }); - // Update the invite code - const updatedInvite = await tx.inviteCode.update({ - where: { id: params.inviteId }, - data: { - name: body.name, - description: body.description, - imageUrl: body.imageUrl, - maxUses: body.maxUses, + // Build metadata update if authorized and fields provided + if ( + canUpdateMetadata && + (body.name !== undefined || + body.description !== undefined || + body.imageUrl !== undefined) + ) { + const metadataUpdate = Object.fromEntries( + Object.entries({ + name: body.name, + description: body.description, + imageUrl: body.imageUrl, + }).filter(([_, value]) => value !== undefined), + ); + + await tx.groupMetadata.upsert({ + where: { id: existingInvite.groupId }, + update: metadataUpdate, + create: { + id: existingInvite.groupId, + name: body.name, + description: body.description, + imageUrl: body.imageUrl, + }, + }); + } + + // Build update object with only provided fields + const fieldsToUpdate = { + maxUses: body.maxUses, + autoApprove: body.autoApprove, + status: body.status, + ...(body.expiresAt !== undefined && { expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, - autoApprove: body.autoApprove, - groupId: body.groupId, - ...(body.status ? { status: body.status } : {}), + }), + }; + + const updateData = { + ...Object.fromEntries( + Object.entries(fieldsToUpdate).filter( + ([_, value]) => value !== undefined, + ), + ), + // Handle notification targets if provided + ...(body.notificationTargets !== undefined && { notificationTargets: { create: notificationTargetIds.map((deviceIdentityId) => ({ deviceIdentityId, })), }, + }), + }; + + // Update the invite code + const updatedInvite = await tx.inviteCode.update({ + where: { id: params.inviteId }, + data: updateData, + include: { + groupMetadata: true, }, }); @@ -163,9 +265,9 @@ export async function updateInviteCode( const response: UpdateInviteCodeResponse = { id: inviteCode.id, - name: inviteCode.name, - description: inviteCode.description, - imageUrl: inviteCode.imageUrl, + name: inviteCode.groupMetadata.name ?? null, + description: inviteCode.groupMetadata.description ?? null, + imageUrl: inviteCode.groupMetadata.imageUrl ?? null, maxUses: inviteCode.maxUses, usesCount: inviteCode.usesCount, status: inviteCode.status, diff --git a/src/api/v1/invites/invites.router.ts b/src/api/v1/invites/invites.router.ts index 6a59fdf3..23861378 100644 --- a/src/api/v1/invites/invites.router.ts +++ b/src/api/v1/invites/invites.router.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { acceptRequestToJoin } from "./handlers/accept-request-to-join"; import { createInviteCode } from "./handlers/create-invite-code"; import { deleteInvite } from "./handlers/delete-invite"; import { deleteRequestToJoin } from "./handlers/delete-request-to-join"; @@ -15,6 +16,7 @@ const invitesRouter = Router(); invitesRouter.post("/", createInviteCode); invitesRouter.post("/request", requestToJoin); invitesRouter.get("/requests", getInviteRequests); +invitesRouter.put("/requests/:requestId/accept", acceptRequestToJoin); invitesRouter.delete("/requests/:requestId", deleteRequestToJoin); invitesRouter.put("/:inviteId", updateInviteCode); invitesRouter.get( diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index c215e759..439ac767 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -59,6 +59,7 @@ const NOTIFICATION_EXTENSION_ALLOWED_ROUTES = [ // Invites routes { method: "GET", path: "/api/v1/invites/requests" }, { method: "DELETE", path: "/api/v1/invites/requests/:requestId" }, + { method: "PUT", path: "/api/v1/invites/requests/:requestId/accept" }, { method: "GET", path: "/api/v1/invites/:inviteId/with-group" }, { method: "GET", path: "/api/v1/invites/:inviteId" }, { method: "DELETE", path: "/api/v1/invites/:inviteId" }, diff --git a/tests/invite-requests.test.ts b/tests/invite-requests.test.ts index c045b9f3..31541b9c 100644 --- a/tests/invite-requests.test.ts +++ b/tests/invite-requests.test.ts @@ -99,10 +99,19 @@ async function createTestInviteCode(args: { autoApprove?: boolean } = {}) { where: { xmtpId: "test-creator-xmtp-id" }, }); + // Create group metadata first + await prisma.groupMetadata.upsert({ + where: { id: "test-group-123" }, + update: {}, + create: { + id: "test-group-123", + name: "Test Group Invite", + }, + }); + return await prisma.inviteCode.create({ data: { groupId: "test-group-123", - name: "Test Group Invite", autoApprove: args.autoApprove ?? false, // Default to requiring approval createdById: creatorIdentity!.id, }, diff --git a/tests/notifications-invites.test.ts b/tests/notifications-invites.test.ts index eb9c4ccb..3a2dd811 100644 --- a/tests/notifications-invites.test.ts +++ b/tests/notifications-invites.test.ts @@ -200,12 +200,21 @@ describe("Invite Notifications Integration", () => { }, }); + // Create group metadata first + await prisma.groupMetadata.upsert({ + where: { id: "test-group-notifications-123" }, + update: {}, + create: { + id: "test-group-notifications-123", + name: "Test Notification Group Invite", + description: "Test invite for notification testing", + }, + }); + // Create invite code const createdInviteCode = await prisma.inviteCode.create({ data: { groupId: "test-group-notifications-123", - name: "Test Notification Group Invite", - description: "Test invite for notification testing", autoApprove: false, // Require approval to trigger notifications createdById: creatorIdentity.id, },