Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 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])
}
216 changes: 216 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,216 @@
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) {
res.status(400).json({
success: false,
message: "Request has already been accepted",
});
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",
});
}
}
66 changes: 47 additions & 19 deletions src/api/v1/invites/handlers/create-invite-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand Down Expand Up @@ -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)

Comment thread
lourou marked this conversation as resolved.
// 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,
},
},
});
});
Comment thread
lourou marked this conversation as resolved.

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,
Expand Down
Loading