All files / src/server/services ItemService.ts

93.18% Statements 82/88
100% Branches 40/40
68.42% Functions 13/19
93.1% Lines 81/87

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 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266                                      19x     19x 19x           41x 41x       8x                 13x 13x 1x   12x 1x               11x 11x 3x           8x 8x           8x       2x 2x 2x         6x                 8x 8x 1x   7x 1x       6x 6x 1x           5x 5x 9x 9x 9x       1x 1x 1x 1x 1x   1x         8x   4x                       20x 20x 1x   19x 1x   18x 18x 3x           15x 5x 5x 1x   4x 4x 4x 10x 3x 3x 3x 3x   7x 7x 7x     14x     16x 16x       14x       14x     20x 16x 16x 5x       14x                                                                                               4x     4x       4x 4x 4x             4x        
import type { Item } from "@/types";
import type { Database } from "@/lib/db";
import { ItemRepository } from "@/server/repositories/implementations/ItemRepository";
import { PhotoRepository } from "@/server/repositories/implementations/PhotoRepository";
import { AuditLogRepository } from "@/server/repositories/implementations/AuditLogRepository";
import { withTransaction } from "@/lib/transactions";
import { MAX_GROUP_PHOTOS, POOL_CAP_MESSAGE } from "@/lib/pool-limits";
 
// Re-exported so existing call sites (API routes) keep importing the cap from
// here — the number itself lives in the shared client-safe module.
export { MAX_GROUP_PHOTOS };
 
export type ReassignDest =
  | { type: "item"; itemId: string }
  | { type: "new" }
  | { type: "pool" };
 
export class PoolGroupError extends Error {
  constructor(
    public readonly code: "empty" | "too_many" | "conflict",
    message: string,
  ) {
    super(message);
    this.name = "PoolGroupError";
  }
}
 
export class ItemService {
  constructor(
    private repo: ItemRepository,
    private photoRepo: PhotoRepository,
  ) {}
 
  static fromDb(db: Database): ItemService {
    return new ItemService(new ItemRepository(db), new PhotoRepository(db));
  }
 
  // Make one item from selected pooled photos: pre-validate availability, then
  // create the (nameless, catalog-numbered) item and assign the photos with the
  // first as hero. The create→assign path is deliberately non-transactional, so we pre-check
  // availability before any write — a conflict must leave the pool untouched —
  // and keep the assign guard as a race backstop that compensates the item.
  async createItemFromPool(collectionId: string, photoIds: string[]): Promise<Item> {
    const ids = [...new Set(photoIds)];
    if (ids.length === 0) {
      throw new PoolGroupError("empty", "Choose at least one photo to make an item.");
    }
    if (ids.length > MAX_GROUP_PHOTOS) {
      throw new PoolGroupError("too_many", POOL_CAP_MESSAGE);
    }
 
    // Pre-validate availability BEFORE creating or assigning anything, so a
    // conflict leaves the pool untouched — no orphan item, no half-assigned
    // photos — partial failure leaves at worst a harmless empty item; this check
    // plus the assign guard below keep conflicts clean (a transaction would also
    // work but isn't used here).
    const available = await this.photoRepo.countAvailablePooled(collectionId, ids);
    if (available !== ids.length) {
      throw new PoolGroupError(
        "conflict",
        "Some of those photos aren't available to group anymore — try again.",
      );
    }
 
    const item = await this.repo.create({ collectionId });
    const assigned = await this.photoRepo.assignPooledPhotosToItem(
      collectionId,
      ids,
      item.id,
      ids[0],
    );
    if (assigned !== ids.length) {
      // Lost a race between the check and the assign (rare). Return any photos
      // that did get assigned to the pool, then remove the empty item — a
      // conflict leaves neither an orphan item nor an orphaned photo.
      await this.photoRepo.unassignAllFromItem(item.id);
      await this.repo.softDelete(item.id);
      throw new PoolGroupError(
        "conflict",
        "Some of those photos aren't available to group anymore — try again.",
      );
    }
    return item;
  }
 
  // Make one item PER photo: a bulk fast-path where N selected photos become N
  // items, each with its single photo as hero. Mirrors createItemFromPool's
  // validation and pre-check discipline — a conflict touches nothing — but loops
  // create+assign per photo. All-or-nothing: if any photo loses a race, every
  // item made this call is rolled back so success always means all were made.
  async createItemsFromPoolEach(collectionId: string, photoIds: string[]): Promise<Item[]> {
    const ids = [...new Set(photoIds)];
    if (ids.length === 0) {
      throw new PoolGroupError("empty", "Choose at least one photo to make items.");
    }
    if (ids.length > MAX_GROUP_PHOTOS) {
      throw new PoolGroupError("too_many", `You can make up to ${MAX_GROUP_PHOTOS} items at once.`);
    }
 
    // Pre-validate availability up front so a conflict touches nothing.
    const available = await this.photoRepo.countAvailablePooled(collectionId, ids);
    if (available !== ids.length) {
      throw new PoolGroupError(
        "conflict",
        "Some of those photos aren't available to group anymore — try again.",
      );
    }
 
    const created: Item[] = [];
    for (const id of ids) {
      const item = await this.repo.create({ collectionId });
      const assigned = await this.photoRepo.assignPooledPhotosToItem(collectionId, [id], item.id, id);
      if (assigned !== 1) {
        // Lost a race for this photo — roll back everything made this call so the
        // action is all-or-nothing (no orphan items, no photos vanishing from the
        // pool view, consistent with the combine path).
        await this.photoRepo.unassignAllFromItem(item.id);
        await this.repo.softDelete(item.id);
        for (const made of created) {
          await this.photoRepo.unassignAllFromItem(made.id);
          await this.repo.softDelete(made.id);
        }
        throw new PoolGroupError(
          "conflict",
          "Some of those photos aren't available to group anymore — try again.",
        );
      }
      created.push(item);
    }
    return created;
  }
 
  // Unified "Move to…": move/split/return-to-pool are all reassigning photos'
  // item_id. Reconciles hero on every affected item (a source whose hero moved
  // away, or a heroless target) so display stays coherent. This path is
  // deliberately non-transactional; findByIds pre-validates so a conflict touches nothing.
  async reassignPhotos(
    collectionId: string,
    photoIds: string[],
    dest: ReassignDest,
  ): Promise<{ number: number | null }> {
    const ids = [...new Set(photoIds)];
    if (ids.length === 0) {
      throw new PoolGroupError("empty", "Choose at least one photo to move.");
    }
    if (ids.length > MAX_GROUP_PHOTOS) {
      throw new PoolGroupError("too_many", `You can move up to ${MAX_GROUP_PHOTOS} photos at once.`);
    }
    const photos = await this.photoRepo.findByIds(collectionId, ids);
    if (photos.length !== ids.length) {
      throw new PoolGroupError("conflict", "Some of those photos aren't available to move anymore — try again.");
    }
 
    let targetItemId: string | null;
    let heroPhotoId: string | null;
    let resultNumber: number | null;
    if (dest.type === "item") {
      const target = await this.repo.findById(dest.itemId);
      if (!target || target.collectionId !== collectionId) {
        throw new PoolGroupError("conflict", "That item isn't available anymore — try again.");
      }
      targetItemId = target.id;
      heroPhotoId = null; // target keeps its own hero
      resultNumber = target.number;
    } else if (dest.type === "new") {
      const created = await this.repo.create({ collectionId });
      targetItemId = created.id;
      heroPhotoId = ids[0];
      resultNumber = created.number;
    } else {
      targetItemId = null;
      heroPhotoId = null;
      resultNumber = null;
    }
 
    const sourceItemIds = [
      ...new Set(
        photos
          .map((p) => p.itemId)
          .filter((id): id is string => !!id && id !== targetItemId),
      ),
    ];
 
    await this.photoRepo.reassignPhotos(collectionId, ids, targetItemId, heroPhotoId);
 
    // Reconcile hero on every affected item (sources + an existing-item target):
    // if it has photos but none is hero, promote its first remaining photo.
    const affected = targetItemId && dest.type === "item"
      ? [...sourceItemIds, targetItemId]
      : sourceItemIds;
    for (const itemId of affected) {
      const remaining = await this.photoRepo.findByItem(itemId);
      if (remaining.length > 0 && !remaining.some((p) => p.isHero)) {
        await this.photoRepo.setHero(remaining[0].id, itemId);
      }
    }
 
    return { number: resultNumber };
  }
 
  async createItem(
    collectionId: string,
    data: {
      name?: string | null;
      brand?: string;
      style?: string;
      color?: string;
      material?: string;
      category?: string;
      status?: string;
      notes?: string;
    },
  ): Promise<Item> {
    return this.repo.create({ collectionId, ...data });
  }
 
  async getItem(itemId: string): Promise<Item | null> {
    return this.repo.findById(itemId);
  }
 
  async getCollectionItems(collectionId: string): Promise<Item[]> {
    return this.repo.findByCollection(collectionId);
  }
 
  async updateItem(itemId: string, data: Partial<Item>): Promise<Item> {
    return this.repo.update(itemId, {
      ...data,
      updatedAt: new Date(),
    });
  }
 
  async deleteItem(itemId: string): Promise<Item> {
    return this.repo.softDelete(itemId);
  }
 
  async restoreItem(itemId: string): Promise<Item> {
    return this.repo.update(itemId, {
      deletedAt: null,
      updatedAt: new Date(),
    });
  }
}
 
/** Thin service that owns the soft-delete+audit transaction for a single item. */
export class ItemSoftDeleteService {
  constructor(private db: Database) {}
 
  static fromDb(db: Database): ItemSoftDeleteService {
    return new ItemSoftDeleteService(db);
  }
 
  async softDeleteWithAudit(item: Item, actorId: string): Promise<Item> {
    return withTransaction(this.db, async (tx) => {
      const row = await new ItemRepository(tx).softDelete(item.id);
      await new AuditLogRepository(tx).record({
        actorId,
        action: "item.delete",
        targetType: "item",
        targetId: item.id,
        collectionId: item.collectionId,
      });
      return row;
    });
  }
}