All files / src/server/repositories/implementations InvitationRepository.ts

85% Statements 17/20
83.33% Branches 10/12
83.33% Functions 10/12
84.21% Lines 16/19

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 106 107 108 109 110 111 112 113 114 115 116 117              253x               165x 165x                         165x       32x       32x       11x       11x       10x       10x                               8x                 8x               14x                 12x         12x       211x                        
import { eq, and } from "drizzle-orm";
import type { Database } from "@/lib/db";
import { invitationsTable } from "@/lib/db/schema";
import type { Invitation, InvitationStatus } from "@/types";
import { generateId } from "@/lib/id";
 
export class InvitationRepository {
  constructor(private db: Database) {}
 
  async create(data: {
    id?: string;
    collectionId: string;
    email: string;
    token: string;
  }): Promise<Invitation> {
    const id = data.id || generateId("inv");
    const [inv] = await this.db
      .insert(invitationsTable)
      .values({
        id,
        collectionId: data.collectionId,
        email: data.email,
        token: data.token,
        status: "pending",
        createdAt: new Date(),
        updatedAt: new Date(),
        acceptedAt: null,
      })
      .returning();
    return this.mapToInvitation(inv);
  }
 
  async findById(id: string): Promise<Invitation | null> {
    const [inv] = await this.db
      .select()
      .from(invitationsTable)
      .where(eq(invitationsTable.id, id));
    return inv ? this.mapToInvitation(inv) : null;
  }
 
  async findByToken(token: string): Promise<Invitation | null> {
    const [inv] = await this.db
      .select()
      .from(invitationsTable)
      .where(eq(invitationsTable.token, token));
    return inv ? this.mapToInvitation(inv) : null;
  }
 
  async findByCollection(collectionId: string): Promise<Invitation[]> {
    const invs = await this.db
      .select()
      .from(invitationsTable)
      .where(eq(invitationsTable.collectionId, collectionId));
    return invs.map((i) => this.mapToInvitation(i));
  }
 
  async findByEmail(email: string, collectionId: string): Promise<Invitation | null> {
    const [inv] = await this.db
      .select()
      .from(invitationsTable)
      .where(
        and(eq(invitationsTable.email, email), eq(invitationsTable.collectionId, collectionId)),
      );
    return inv ? this.mapToInvitation(inv) : null;
  }
 
  async update(id: string, data: Partial<Invitation>): Promise<Invitation> {
    // Allow-list: only status and acceptedAt may be updated by callers.
    // Flags, email, collectionId, and token are immutable after creation.
    const [inv] = await this.db
      .update(invitationsTable)
      .set({
        ...(data.status !== undefined && { status: data.status }),
        ...(data.acceptedAt !== undefined && { acceptedAt: data.acceptedAt }),
        updatedAt: new Date(),
      })
      .where(eq(invitationsTable.id, id))
      .returning();
    return this.mapToInvitation(inv);
  }
 
  async delete(id: string): Promise<void> {
    await this.db.delete(invitationsTable).where(eq(invitationsTable.id, id));
  }
 
  async deleteByCollection(collectionId: string): Promise<void> {
    await this.db.delete(invitationsTable).where(eq(invitationsTable.collectionId, collectionId));
  }
 
  // Scrub the email (NOT delete the row). Used during account deletion for an
  // invitation the user accepted in ANOTHER owner's collection: the invitation
  // belongs to that owner's collection (survives), but its email must no longer
  // identify the deleted user. Matches by the user's OLD email. Returns the
  // affected-row count.
  async scrubEmail(oldEmail: string, tombstone: string): Promise<number> {
    const rows = await this.db
      .update(invitationsTable)
      .set({ email: tombstone, updatedAt: new Date() })
      .where(eq(invitationsTable.email, oldEmail))
      .returning({ id: invitationsTable.id });
    return rows.length;
  }
 
  private mapToInvitation(row: typeof invitationsTable.$inferSelect): Invitation {
    return {
      id: row.id,
      collectionId: row.collectionId,
      email: row.email,
      token: row.token,
      status: row.status as InvitationStatus,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
      acceptedAt: row.acceptedAt,
    };
  }
}