All files / src/server/services InvitationAcceptService.ts

92.85% Statements 26/28
81.25% Branches 13/16
100% Functions 7/7
96.15% Lines 25/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 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105                        6x   6x                                     11x     11x         3x                     3x 3x   2x 2x   2x   2x                       8x 8x 8x 1x   7x 2x     5x 5x     5x 1x     4x 4x         4x           4x      
import type { Database } from "@/lib/db";
import type { Invitation, InvitationStatus } from "@/types";
import { InvitationRepository } from "@/server/repositories/implementations/InvitationRepository";
import { MembershipRepository } from "@/server/repositories/implementations/MembershipRepository";
import { UserRepository } from "@/server/repositories/implementations/UserRepository";
import { CollectionRepository } from "@/server/repositories/implementations/CollectionRepository";
import { withTransaction } from "@/lib/transactions";
import { normalizeEmail } from "@/lib/normalize-email";
 
export class InvitationAcceptError extends Error {
  constructor(
    message: string,
    readonly status: 400 | 401 | 403 | 404 | 409,
  ) {
    super(message);
  }
}
 
/**
 * The GET preview payload — deliberately narrower than the raw `Invitation` row.
 * No token echo (the caller already holds it) and no ids: only what the
 * emailed recipient already legitimately sees or needs (spec Global
 * Constraints, purses-puuj.3).
 */
export interface InvitationPreview {
  collectionName: string;
  collectionDescription: string | null;
  inviterName: string | null;
  invitedEmail: string;
  status: InvitationStatus;
}
 
export class InvitationAcceptService {
  constructor(private db: Database) {}
 
  static fromDb(db: Database): InvitationAcceptService {
    return new InvitationAcceptService(db);
  }
 
  /** Look up an invitation by token — internal (accept()) and the raw-row callers. */
  async findByToken(token: string): Promise<Invitation | null> {
    return new InvitationRepository(this.db).findByToken(token);
  }
 
  /**
   * Shaped preview for the GET (public) path: joins the collection and its
   * owner (= inviter — only owners can invite) server-side, so the caller
   * never sees the raw row, the token, or any id. Returns null on a bad/
   * expired token OR a collection that no longer exists — either way there's
   * nothing to preview, and the route maps null to 404 either way.
   */
  async getPreview(token: string): Promise<InvitationPreview | null> {
    const invitation = await this.findByToken(token);
    if (!invitation) return null;
 
    const collection = await new CollectionRepository(this.db).findById(invitation.collectionId);
    Iif (!collection) return null;
 
    const owner = await new UserRepository(this.db).findById(collection.ownerId);
 
    return {
      collectionName: collection.name,
      collectionDescription: collection.description,
      // Never an email — a display name only, and only if one exists.
      inviterName: owner?.name ?? null,
      invitedEmail: invitation.email,
      status: invitation.status,
    };
  }
 
  /** Accept an invitation: validate status + email, then atomically create membership + mark accepted. */
  async accept(token: string, userId: string): Promise<{ collectionId: string }> {
    const invRepo = new InvitationRepository(this.db);
    const invitation = await invRepo.findByToken(token);
    if (!invitation) {
      throw new InvitationAcceptError("Invitation not found or expired", 404);
    }
    if (invitation.status !== "pending") {
      throw new InvitationAcceptError("Invitation is no longer pending", 409);
    }
 
    const dbUser = await new UserRepository(this.db).findById(userId);
    Iif (!dbUser) {
      throw new InvitationAcceptError("User not found", 404);
    }
    if (!dbUser.email || normalizeEmail(dbUser.email) !== normalizeEmail(invitation.email)) {
      throw new InvitationAcceptError("This invitation is for a different account", 403);
    }
 
    await withTransaction(this.db, async (tx) => {
      await new MembershipRepository(tx).createIfAbsent({
        collectionId: invitation.collectionId,
        userId,
        role: "curator",
      });
      await new InvitationRepository(tx).update(invitation.id, {
        status: "accepted",
        acceptedAt: new Date(),
      });
    });
 
    return { collectionId: invitation.collectionId };
  }
}