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

94% Statements 47/50
88.46% Branches 23/26
85% Functions 17/20
95.34% Lines 41/43

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 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216                  634x               19x 19x 19x                           19x         537x       537x       46x       46x       4x       4x                       10x                                               10x 10x 13x   10x             13x       13x                   8x 8x   8x 8x                 8x             3x         3x           16x       16x           13x                   22x 22x 28x 28x 27x     28x       27x         588x                               1x 1x 1x 6x   1x                
import { eq, isNull, and, or, ne, exists, asc, desc } from "drizzle-orm";
import type { Database } from "@/lib/db";
import { collectionsTable, membershipsTable } from "@/lib/db/schema";
import type { Collection, CollectionStatus } from "@/types";
import { slugifyName, RESERVED_COLLECTION_SLUGS } from "@/lib/collections/slug";
import { allocateWithRetry } from "../allocateWithRetry";
import { isUniqueViolation } from "../pg-conflict";
 
export class CollectionRepository {
  constructor(private db: Database) {}
 
  async create(data: {
    id: string;
    ownerId: string;
    name: string;
    description?: string;
  }): Promise<Collection> {
    return allocateWithRetry(async () => {
      const slug = await this.generateUniqueSlug(data.name);
      const [collection] = await this.db
        .insert(collectionsTable)
        .values({
          id: data.id,
          ownerId: data.ownerId,
          name: data.name,
          slug,
          description: data.description || null,
          status: "active",
          createdAt: new Date(),
          updatedAt: new Date(),
          deletedAt: null,
        })
        .returning();
      return this.mapToCollection(collection);
    }, isSlugConflict);
  }
 
  async findById(id: string): Promise<Collection | null> {
    const [collection] = await this.db
      .select()
      .from(collectionsTable)
      .where(and(eq(collectionsTable.id, id), isNull(collectionsTable.deletedAt)));
    return collection ? this.mapToCollection(collection) : null;
  }
 
  async findBySlug(slug: string): Promise<Collection | null> {
    const [collection] = await this.db
      .select()
      .from(collectionsTable)
      .where(and(eq(collectionsTable.slug, slug), isNull(collectionsTable.deletedAt)));
    return collection ? this.mapToCollection(collection) : null;
  }
 
  async findByOwner(ownerId: string): Promise<Collection[]> {
    const collections = await this.db
      .select()
      .from(collectionsTable)
      .where(and(eq(collectionsTable.ownerId, ownerId), isNull(collectionsTable.deletedAt)));
    return collections.map((c) => this.mapToCollection(c));
  }
 
  // Collections the user can see in their list: owned ∪ those where a membership
  // row exists for the user (a row is created ONLY on invitation-accept, so
  // "row exists" == "accepted member"). Deliberately does NOT read invitations:
  // a PENDING invite must not surface a collection the user never joined
  // (IDOR-by-list). One query via an EXISTS-membership OR ownerId predicate;
  // the (collection_id, user_id) unique index means each collection matches at
  // most once, but the caller may still own AND have a row for the same id, so
  // dedupe by id (the Map keeps the query order). Excludes soft-deleted, matching findByOwner.
  async findByOwnerOrMember(userId: string): Promise<Collection[]> {
    const rows = await this.db
      .select()
      .from(collectionsTable)
      .where(
        and(
          isNull(collectionsTable.deletedAt),
          or(
            eq(collectionsTable.ownerId, userId),
            exists(
              this.db
                .select({ one: membershipsTable.id })
                .from(membershipsTable)
                .where(
                  and(
                    eq(membershipsTable.collectionId, collectionsTable.id),
                    eq(membershipsTable.userId, userId),
                  ),
                ),
            ),
          ),
        ),
      )
      // Newest first; id breaks a created_at tie so the grid never reshuffles.
      .orderBy(desc(collectionsTable.createdAt), asc(collectionsTable.id));
    const byId = new Map<string, Collection>();
    for (const row of rows) {
      Eif (!byId.has(row.id)) byId.set(row.id, this.mapToCollection(row));
    }
    return [...byId.values()];
  }
 
  // Resolve EVERY collection owned by the user WITHOUT the deletedAt filter —
  // account deletion must purge a user's soft-deleted (archived) collections too,
  // which findByOwner (active-only) would skip. Mirrors findByIdIncludingDeleted.
  async findByOwnerIncludingDeleted(ownerId: string): Promise<Collection[]> {
    const collections = await this.db
      .select()
      .from(collectionsTable)
      .where(eq(collectionsTable.ownerId, ownerId));
    return collections.map((c) => this.mapToCollection(c));
  }
 
  async update(
    id: string,
    data: Partial<Omit<Collection, "id" | "createdAt" | "slug">>,
  ): Promise<Collection> {
    // Regenerate the slug when the name changes (excluding this row from dedupe).
    // On a lost slug race the closure recomputes against the now-current set;
    // a non-renaming update has no slug to recompute, so it is never retried.
    const renaming = data.name !== undefined;
    return allocateWithRetry(
      async () => {
        const newSlug = renaming ? await this.generateUniqueSlug(data.name!, id) : undefined;
        const [collection] = await this.db
          .update(collectionsTable)
          .set({
            ...data,
            ...(newSlug !== undefined ? { slug: newSlug } : {}),
            updatedAt: new Date(),
          })
          .where(eq(collectionsTable.id, id))
          .returning();
        return this.mapToCollection(collection);
      },
      (error) => renaming && isSlugConflict(error),
    );
  }
 
  async softDelete(id: string): Promise<Collection> {
    const [collection] = await this.db
      .update(collectionsTable)
      .set({ deletedAt: new Date(), updatedAt: new Date() })
      .where(eq(collectionsTable.id, id))
      .returning();
    return this.mapToCollection(collection);
  }
 
  // Resolve a collection WITHOUT the deletedAt filter — purge must find a
  // soft-deleted collection too (findById can't, so it can't be reused here).
  async findByIdIncludingDeleted(id: string): Promise<Collection | null> {
    const [collection] = await this.db
      .select()
      .from(collectionsTable)
      .where(eq(collectionsTable.id, id));
    return collection ? this.mapToCollection(collection) : null;
  }
 
  // Permanent DELETE (purge). Caller has already removed every FK-referencing
  // child (items, photos, sessions, results, memberships, invitations, …).
  async hardDelete(id: string): Promise<void> {
    await this.db.delete(collectionsTable).where(eq(collectionsTable.id, id));
  }
 
  /**
   * First free value among `base`, `base-2`, `base-3`, … where "free" means not
   * reserved and not present in ANY row (incl. soft-deleted, which still hold
   * their slug in the unique index) other than `excludeId` (the row being
   * renamed). Empty-slugifiable names get a random fallback base.
   */
  private async generateUniqueSlug(name: string, excludeId?: string): Promise<string> {
    const base = slugifyName(name) || randomSlug();
    for (let n = 1; ; n++) {
      const candidate = n === 1 ? base : `${base}-${n}`;
      if (RESERVED_COLLECTION_SLUGS.has(candidate)) continue;
      const where = excludeId
        ? and(eq(collectionsTable.slug, candidate), ne(collectionsTable.id, excludeId))
        : eq(collectionsTable.slug, candidate);
      const [row] = await this.db
        .select({ id: collectionsTable.id })
        .from(collectionsTable)
        .where(where);
      if (!row) return candidate;
    }
  }
 
  private mapToCollection(row: typeof collectionsTable.$inferSelect): Collection {
    return {
      id: row.id,
      ownerId: row.ownerId,
      name: row.name,
      slug: row.slug,
      description: row.description,
      status: row.status as CollectionStatus,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
      deletedAt: row.deletedAt,
    };
  }
}
 
/** Crockford-base32 (no I/L/O/U), lowercased, 6 chars. */
function randomSlug(): string {
  const alphabet = "0123456789abcdefghjkmnpqrstvwxyz";
  let out = "";
  for (let i = 0; i < 6; i++) {
    out += alphabet[Math.floor(Math.random() * alphabet.length)];
  }
  return out;
}
 
function isSlugConflict(error: unknown): boolean {
  // Retry ONLY the UNIQUE(slug) race — any other unique violation is a real bug
  // and must surface. Constraint name matches the Drizzle-generated index.
  return isUniqueViolation(error, "collections_slug_unique");
}