Add group management and event-specific invitation onboarding
This commit is contained in:
@@ -55,6 +55,33 @@ async function validateInviteGrants(userId: string, input: {
|
||||
}
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
rename: protectedProcedure
|
||||
.input(z.object({ groupId: z.string().uuid(), name: z.string().trim().min(1).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
|
||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.GROUP_MANAGE);
|
||||
await getDb().transaction(async tx => {
|
||||
await tx.update(groups).set({ name: input.name, updatedAt: new Date() }).where(eq(groups.id, input.groupId));
|
||||
await tx.insert(auditEvents).values({ groupId: input.groupId, actorUserId: ctx.session.user.id, action: "group.rename", subjectType: "group", subjectId: input.groupId });
|
||||
});
|
||||
return { name: input.name };
|
||||
}),
|
||||
|
||||
revokeInvite: protectedProcedure
|
||||
.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
|
||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||
await getDb().transaction(async tx => {
|
||||
const [invite] = await tx.update(invites).set({ status: "revoked", updatedAt: new Date() })
|
||||
.where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId), eq(invites.status, "pending")))
|
||||
.returning({ id: invites.id, eventId: invites.eventId });
|
||||
if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite is no longer pending." });
|
||||
await tx.insert(auditEvents).values({ groupId: input.groupId, eventId: invite.eventId, actorUserId: ctx.session.user.id, action: "invite.revoke", subjectType: "invite", subjectId: invite.id });
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
pendingInvites: protectedProcedure
|
||||
.input(z.object({ groupId: z.string().uuid(), eventId: z.string().uuid().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { redeemInviteInputSchema } from "@album/contracts";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { findInviteByToken, redeemInviteForUser } from "@/server/invites";
|
||||
import { assertInviteRedeemable, findInviteByToken, redeemInviteForUser } from "@/server/invites";
|
||||
import { events, groups, getDb } from "@album/database";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
|
||||
import { galleryIsPublic } from "@/lib/publishing";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getDeploymentSettings } from "@/server/settings";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
@@ -23,7 +27,16 @@ export const invitesRouter = createTRPCRouter({
|
||||
if (!invite) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||
}
|
||||
assertInviteRedeemable(invite);
|
||||
const [event] = invite.eventId && invite.groupId ? await getDb().select().from(events)
|
||||
.where(and(eq(events.id, invite.eventId), eq(events.groupId, invite.groupId))).limit(1) : [];
|
||||
const [group] = invite.groupId ? await getDb().select({ name: groups.name }).from(groups).where(eq(groups.id, invite.groupId)).limit(1) : [];
|
||||
const bannerUrl = event?.customBannerId ? await customBannerUrl(event.id, event.customBannerId)
|
||||
: event && galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, event.bannerPhotoId) : null;
|
||||
return {
|
||||
eventTitle: event?.title ?? null,
|
||||
groupName: group?.name ?? null,
|
||||
bannerUrl,
|
||||
kind: invite.kind,
|
||||
status: invite.status,
|
||||
groupRole: invite.groupRole,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { hashToken } from "./tokens";
|
||||
import { redeemInviteForUser } from "./invites";
|
||||
import { signsRouter } from "./api/routers/signs";
|
||||
import { groupRouter } from "./api/routers/group";
|
||||
import { invitesRouter } from "./api/routers/invites";
|
||||
import type { TrpcContext } from "./api/trpc";
|
||||
|
||||
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => {
|
||||
@@ -28,7 +29,12 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
|
||||
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
|
||||
expect(used!.usedCount).toBe(1);
|
||||
const workspace = groupRouter.createCaller(ctx(0));
|
||||
const [pending] = await db.insert(invites).values({ kind: "email", email: people[1]!.email, tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, eventId: event!.id, eventRole: "manager" }).returning();
|
||||
const pendingToken = crypto.randomUUID();
|
||||
const [pending] = await db.insert(invites).values({ kind: "email", email: people[1]!.email, tokenHash: hashToken(pendingToken), groupId: group!.id, eventId: event!.id, eventRole: "manager" }).returning();
|
||||
const preview = await invitesRouter.createCaller(ctx(1)).preview({ token: pendingToken });
|
||||
expect(preview.eventTitle).toBe("Polish test");
|
||||
expect(preview.eventRole).toBe("manager");
|
||||
expect(preview).not.toHaveProperty("email");
|
||||
await db.insert(invites).values([
|
||||
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, expiresAt: new Date(0) },
|
||||
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, usedCount: 1, maxUses: 1 },
|
||||
@@ -40,6 +46,17 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
|
||||
expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(1);
|
||||
await expect(groupRouter.createCaller(ctx(1)).pendingInvites({ groupId: group!.id })).rejects.toThrow();
|
||||
await expect(workspace.pendingInvites({ groupId: crypto.randomUUID(), eventId: event!.id })).rejects.toThrow();
|
||||
await expect(groupRouter.createCaller(ctx(1)).rename({ groupId: group!.id, name: "Denied" })).rejects.toThrow();
|
||||
expect(await workspace.rename({ groupId: group!.id, name: " Updated group " })).toEqual({ name: "Updated group" });
|
||||
expect((await workspace.get({ groupId: group!.id })).name).toBe("Updated group");
|
||||
await expect(workspace.setMember({ groupId: group!.id, userId: people[0]!.id, role: "member" })).rejects.toThrow("at least one owner");
|
||||
await expect(groupRouter.createCaller(ctx(1)).revokeInvite({ groupId: group!.id, inviteId: pending!.id })).rejects.toThrow();
|
||||
await expect(workspace.revokeInvite({ groupId: group!.id, inviteId: crypto.randomUUID() })).rejects.toThrow();
|
||||
await workspace.revokeInvite({ groupId: group!.id, inviteId: pending!.id });
|
||||
await expect(invitesRouter.createCaller(ctx(1)).preview({ token: pendingToken })).rejects.toThrow("revoked");
|
||||
expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(0);
|
||||
const [revoked] = await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.id, pending!.id)));
|
||||
expect(revoked!.status).toBe("revoked");
|
||||
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
|
||||
const signs = signsRouter.createCaller(ctx(0));
|
||||
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
|
||||
|
||||
Reference in New Issue
Block a user