All files / src/server/api invitations.ts

80.76% Statements 21/26
50% Branches 3/6
87.5% Functions 7/8
80.76% Lines 21/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83                15x               15x       16x     9x       1x             8x 8x 8x   8x             6x 6x                             2x 2x       1x 1x     1x       4x 4x 1x   3x 3x      
import { z } from "zod";
import type { Invitation } from "@/types";
import { randomBytes } from "crypto";
import { normalizeEmail } from "@/lib/normalize-email";
import type { Database } from "@/lib/db";
import { InvitationRepository } from "@/server/repositories/implementations/InvitationRepository";
import { sendInvitation } from "@/lib/email";
 
const CreateInvitationSchema = z.object({
  email: z.string().email(),
});
 
// Invitation flags are inert (DB-default only) and not mutable via PATCH — the
// repo update allow-list writes only status/acceptedAt. An empty schema keeps the
// member side and invitation side symmetric: an unknown flag key is stripped, not
// a 400. (Flag columns/fields are dropped in the deferred contract migration.)
const UpdateInvitationSchema = z.object({});
 
 
export class InvitationController {
  constructor(private invitationRepo: InvitationRepository) {}
 
  static fromDb(db: Database): InvitationController {
    return new InvitationController(new InvitationRepository(db));
  }
 
  async listByCollection(collectionId: string): Promise<Invitation[]> {
    return this.invitationRepo.findByCollection(collectionId);
  }
 
  async createInvitation(
    collectionId: string,
    data: unknown,
  ): Promise<Invitation & { token: string; emailSent: boolean }> {
    const parsed = CreateInvitationSchema.parse(data);
    const email = normalizeEmail(parsed.email);
    const token = randomBytes(32).toString("hex");
    // Flags are not accepted from callers — DB column defaults govern all permissions.
    const invitation = await this.invitationRepo.create({
      collectionId,
      email,
      token,
    });
    // The invitation itself is always created — email is best-effort delivery,
    // so a transport miss (e.g. no RESEND_API_KEY) never fails the request.
    const { sent } = await sendInvitation({ to: email, token });
    return { ...invitation, token, emailSent: sent };
  }
 
  async getInvitation(invitationId: string): Promise<Invitation> {
    const invitation = await this.invitationRepo.findById(invitationId);
    if (!invitation) {
      throw new Error("Invitation not found");
    }
    return invitation;
  }
 
  async updateInvitation(
    invitationId: string,
    data: unknown,
  ): Promise<Invitation> {
    const parsed = UpdateInvitationSchema.parse(data);
    return this.invitationRepo.update(invitationId, parsed);
  }
 
  async revokeInvitation(invitationId: string): Promise<void> {
    const invitation = await this.invitationRepo.findById(invitationId);
    Iif (!invitation) {
      throw new Error("Invitation not found");
    }
    await this.invitationRepo.update(invitationId, { status: "revoked" });
  }
 
  async resendInvitation(invitationId: string): Promise<{ message: string; emailSent: boolean }> {
    const invitation = await this.invitationRepo.findById(invitationId);
    if (!invitation) {
      throw new Error("Invitation not found");
    }
    const { sent } = await sendInvitation({ to: invitation.email, token: invitation.token });
    return { message: "Invitation resent", emailSent: sent };
  }
}