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

100% Statements 19/19
78.57% Branches 11/14
100% Functions 13/13
100% Lines 18/18

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 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140              796x               197x       197x                         4x             4x                 201x                     16x       16x       517x                 517x                 13x                 13x                     1x         1x       1x       14x             12x       299x                    
import { eq, and } from "drizzle-orm";
import type { Database } from "@/lib/db";
import { membershipsTable, user } from "@/lib/db/schema";
import type { CollectionMembership, MembershipRole } from "@/types";
import { generateId } from "@/lib/id";
 
export class MembershipRepository {
  constructor(private db: Database) {}
 
  async create(data: {
    id?: string;
    collectionId: string;
    userId: string;
    role?: "owner" | "curator";
  }): Promise<CollectionMembership> {
    const [mem] = await this.db
      .insert(membershipsTable)
      .values(this.toInsertValues(data))
      .returning();
    return this.mapToMembership(mem);
  }
 
  async createIfAbsent(data: {
    id?: string;
    collectionId: string;
    userId: string;
    role?: "owner" | "curator";
  }): Promise<CollectionMembership | null> {
    // onConflictDoNothing on the (collection_id, user_id) unique index: a
    // duplicate insert is a no-op that raises NO error, so this is safe to call
    // inside a transaction (postgres-js would otherwise latch a 23505 error and
    // roll back the whole tx at scope end). Returns null when nothing was inserted.
    const [mem] = await this.db
      .insert(membershipsTable)
      .values(this.toInsertValues(data))
      .onConflictDoNothing({
        target: [membershipsTable.collectionId, membershipsTable.userId],
      })
      .returning();
    return mem ? this.mapToMembership(mem) : null;
  }
 
  private toInsertValues(data: {
    id?: string;
    collectionId: string;
    userId: string;
    role?: "owner" | "curator";
  }) {
    return {
      id: data.id || generateId("mem"),
      collectionId: data.collectionId,
      userId: data.userId,
      role: data.role ?? "curator",
      createdAt: new Date(),
      updatedAt: new Date(),
    };
  }
 
  async findById(id: string): Promise<CollectionMembership | null> {
    const [mem] = await this.db
      .select()
      .from(membershipsTable)
      .where(eq(membershipsTable.id, id));
    return mem ? this.mapToMembership(mem) : null;
  }
 
  async findByUser(userId: string, collectionId: string): Promise<CollectionMembership | null> {
    const [mem] = await this.db
      .select()
      .from(membershipsTable)
      .where(
        and(
          eq(membershipsTable.userId, userId),
          eq(membershipsTable.collectionId, collectionId),
        ),
      );
    return mem ? this.mapToMembership(mem) : null;
  }
 
  async findByCollection(collectionId: string): Promise<CollectionMembership[]> {
    // LEFT JOIN the user table so each accepted member carries the person-side
    // name + email the Members screen renders (avatar initial + email line).
    // The base memberships table holds only user_id; without this join those
    // optional fields stay undefined. LEFT (not INNER) so a membership whose
    // user row somehow went missing still lists, just without name/email.
    const rows = await this.db
      .select({
        membership: membershipsTable,
        name: user.name,
        email: user.email,
      })
      .from(membershipsTable)
      .leftJoin(user, eq(membershipsTable.userId, user.id))
      .where(eq(membershipsTable.collectionId, collectionId));
    return rows.map((r) => ({
      ...this.mapToMembership(r.membership),
      name: r.name ?? undefined,
      email: r.email ?? undefined,
    }));
  }
 
  async update(id: string, _data: Partial<CollectionMembership>): Promise<CollectionMembership> {
    // Allow-list: no caller-supplied fields are permitted through membership update.
    // Role, flags, collectionId, and userId are immutable after creation.
    // Only updatedAt is written to mark the row touched.
    const [mem] = await this.db
      .update(membershipsTable)
      .set({ updatedAt: new Date() })
      .where(eq(membershipsTable.id, id))
      .returning();
    return this.mapToMembership(mem);
  }
 
  async delete(id: string): Promise<void> {
    await this.db.delete(membershipsTable).where(eq(membershipsTable.id, id));
  }
 
  async deleteByCollection(collectionId: string): Promise<void> {
    await this.db.delete(membershipsTable).where(eq(membershipsTable.collectionId, collectionId));
  }
 
  // Delete every membership for the given user across all collections.
  // Used during account deletion to remove the user's curator memberships in
  // borrowed collections (after child rows have been removed). Account-deletion only.
  async deleteByUser(userId: string): Promise<void> {
    await this.db.delete(membershipsTable).where(eq(membershipsTable.userId, userId));
  }
 
  private mapToMembership(row: typeof membershipsTable.$inferSelect): CollectionMembership {
    return {
      id: row.id,
      collectionId: row.collectionId,
      userId: row.userId,
      role: row.role as MembershipRole,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    };
  }
}