Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
Comment thread
lourou marked this conversation as resolved.

-- AddForeignKey
ALTER TABLE "InviteCode" ADD CONSTRAINT "InviteCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "GroupMetadata"("id") ON DELETE CASCADE ON UPDATE CASCADE;
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");
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;
19 changes: 17 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -135,6 +147,8 @@ model InviteCode {
@@index([status])
@@index([createdById])
@@index([groupId])
@@index([status, expiresAt])
@@index([createdById, groupId])
}

model InviteCodeUse {
Expand Down Expand Up @@ -175,4 +189,5 @@ model InviteCodeRequest {
@@unique([inviteCodeId, requesterId])
@@index([inviteCodeId])
@@index([requesterId])
@@index([inviteCodeId, createdAt])
}
222 changes: 222 additions & 0 deletions src/api/v1/invites/handlers/accept-request-to-join.ts
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: {
Comment thread
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,
Comment thread
lourou marked this conversation as resolved.
Outdated
},
data: {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
usesCount: {
increment: 1,
},
},
});
Comment thread
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,
Comment thread
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",
});
}
}
Loading