mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
refactor(collections): access model, hydration, export/import and list CRUD through repositories
This commit is contained in:
@@ -1,4 +1,76 @@
|
||||
import type { CollectionLabels } from '../entities/CollectionLabels.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class CollectionLabelsRepository extends TrekRepository<CollectionLabels> {}
|
||||
/** `collection_labels` — `loadLabelsByCollection`'s (CL7) shape. */
|
||||
export interface CollectionLabelRow {
|
||||
id: number;
|
||||
collection_id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
sort_order: number | null;
|
||||
}
|
||||
|
||||
interface CollectionLabelsKyselyDB {
|
||||
collection_labels: {
|
||||
id: number;
|
||||
collection_id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** CL20's own `INSERT INTO collection_labels (collection_id, name, color, sort_order)` column list, exactly — no `id`/`created_at`, the `BucketListInsertKyselyDB` narrower-insert-interface precedent. */
|
||||
interface CollectionLabelsInsertKyselyDB {
|
||||
collection_labels: { collection_id: number; name: string; color: string; sort_order: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* `collection_labels` — Plan 3h Task 1's first cut. Owns Part A's own
|
||||
* label reads/insert (CL7, `labelIdsForFile`'s CL18/CL19, `insertImportedLabel`'s
|
||||
* CL20). Task 2 extends this repository with `createLabel`/`updateLabel`/
|
||||
* `deleteLabel`/`collectionIdOfLabel`/`getLabelById` (CL71-77, beyond this
|
||||
* task's CL1-36 range).
|
||||
*/
|
||||
export class CollectionLabelsRepository extends TrekRepository<CollectionLabels> {
|
||||
private db_() {
|
||||
return this.kysely<CollectionLabelsKyselyDB>();
|
||||
}
|
||||
|
||||
/** CL7 (`loadLabelsByCollection`) — `SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id=? ORDER BY sort_order, id`. */
|
||||
async listByCollection(collectionId: number): Promise<CollectionLabelRow[]> {
|
||||
return await this.db_()
|
||||
.selectFrom('collection_labels')
|
||||
.select(['id', 'collection_id', 'name', 'color', 'sort_order'])
|
||||
.where('collection_id', '=', collectionId)
|
||||
.orderBy('sort_order')
|
||||
.orderBy('id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL18 (`labelIdsForFile`) — `SELECT id, name FROM collection_labels WHERE collection_id=?`. */
|
||||
async idNameByCollection(collectionId: number): Promise<{ id: number; name: string }[]> {
|
||||
return await this.db_()
|
||||
.selectFrom('collection_labels')
|
||||
.select(['id', 'name'])
|
||||
.where('collection_id', '=', collectionId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL19 (`labelIdsForFile`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collection_labels WHERE collection_id=?`. Dup text also `createLabel` (Task 2's own call site, CL73's neighbor). */
|
||||
async maxSortOrder(collectionId: number): Promise<number> {
|
||||
const row = await this.db_()
|
||||
.selectFrom('collection_labels')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m'))
|
||||
.where('collection_id', '=', collectionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(row.m);
|
||||
}
|
||||
|
||||
/** CL20 (`insertImportedLabel`) — `INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?,?,?,?)`. Returns the new row's id. Dup text also `createLabel` (Task 2's own call site). */
|
||||
async insertLabel(row: { collection_id: number; name: string; color: string; sort_order: number }): Promise<number> {
|
||||
const result = await this.kysely<CollectionLabelsInsertKyselyDB>().insertInto('collection_labels').values(row).executeTakeFirstOrThrow();
|
||||
return Number(result.insertId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,114 @@
|
||||
import type { CollectionMembers } from '../entities/CollectionMembers.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class CollectionMembersRepository extends TrekRepository<CollectionMembers> {}
|
||||
/** `buildMembers`'s owner row — `collections` joined to `users` (CL12). */
|
||||
export interface CollectionOwnerMemberRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/** `buildMembers`'s accepted/pending member rows — `collection_members` joined to `users` (CL13). */
|
||||
export interface CollectionMemberRow extends CollectionOwnerMemberRow {
|
||||
status: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface CollectionOwnerKyselyDB {
|
||||
collections: { id: number; owner_id: number };
|
||||
users: { id: number; username: string; email: string; avatar: string | null };
|
||||
}
|
||||
|
||||
interface CollectionMembersKyselyDB {
|
||||
collection_members: { collection_id: number; user_id: number; status: string; role: string };
|
||||
users: { id: number; username: string; email: string; avatar: string | null };
|
||||
}
|
||||
|
||||
interface CollectionMemberUserIdsKyselyDB {
|
||||
collection_members: { collection_id: number; user_id: number; status: string };
|
||||
}
|
||||
|
||||
/** `listCollections`'s incoming-invites read — a genuine statement missing
|
||||
* its own CL number in the plan's inventory gather (jumped CL15 → CL16
|
||||
* without capturing it): `SELECT cm.collection_id, c.name, u.id AS from_id,
|
||||
* u.username AS from_username FROM collection_members cm JOIN collections c
|
||||
* ON c.id=cm.collection_id JOIN users u ON u.id=c.owner_id WHERE
|
||||
* cm.user_id=? AND cm.status='pending'`. */
|
||||
export interface CollectionIncomingInviteRow {
|
||||
collection_id: number;
|
||||
name: string;
|
||||
from_id: number;
|
||||
from_username: string;
|
||||
}
|
||||
|
||||
interface CollectionIncomingInvitesKyselyDB {
|
||||
collection_members: { collection_id: number; user_id: number; status: string };
|
||||
collections: { id: number; name: string; owner_id: number };
|
||||
users: { id: number; username: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* `collection_members` — Plan 3h Task 1's first cut (`roleOf`'s own member
|
||||
* lookup lives in `_shared/collection-role.ts` instead, R6). Task 2 extends
|
||||
* this repository with `sendInvite`/`acceptInvite`/etc.'s invite-lifecycle
|
||||
* methods (CL79-88) and the membership-lookup surface (CL69, `findMembership`).
|
||||
* Owns `buildMembers`'s two joined reads (CL12/CL13) and `deleteCollection`'s
|
||||
* snapshot-before-cascade reads (CL33/CL34).
|
||||
*/
|
||||
export class CollectionMembersRepository extends TrekRepository<CollectionMembers> {
|
||||
/** CL12 (`buildMembers`) — `SELECT u.id AS user_id, u.username, u.email, u.avatar FROM collections col JOIN users u ON u.id=col.owner_id WHERE col.id=?`. */
|
||||
async ownerRow(collectionId: number): Promise<CollectionOwnerMemberRow | undefined> {
|
||||
return await this.kysely<CollectionOwnerKyselyDB>()
|
||||
.selectFrom('collections as col')
|
||||
.innerJoin('users as u', 'u.id', 'col.owner_id')
|
||||
.select(['u.id as user_id', 'u.username', 'u.email', 'u.avatar'])
|
||||
.where('col.id', '=', collectionId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** CL13 (`buildMembers`) — `SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role FROM collection_members cm JOIN users u ON u.id=cm.user_id WHERE cm.collection_id=? ORDER BY u.username`. */
|
||||
async memberRows(collectionId: number): Promise<CollectionMemberRow[]> {
|
||||
return await this.kysely<CollectionMembersKyselyDB>()
|
||||
.selectFrom('collection_members as cm')
|
||||
.innerJoin('users as u', 'u.id', 'cm.user_id')
|
||||
.select(['u.id as user_id', 'u.username', 'u.email', 'u.avatar', 'cm.status', 'cm.role'])
|
||||
.where('cm.collection_id', '=', collectionId)
|
||||
.orderBy('u.username')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL33 (`deleteCollection`'s snapshot, taken BEFORE the cascading delete) — `SELECT user_id FROM collection_members WHERE collection_id=? AND status='accepted'`. Dup text also `collectionMemberIds`/`notifyCollectionUsers` (Task 2's own call sites). */
|
||||
async acceptedUserIds(collectionId: number): Promise<number[]> {
|
||||
const rows = await this.kysely<CollectionMemberUserIdsKyselyDB>()
|
||||
.selectFrom('collection_members')
|
||||
.select('user_id')
|
||||
.where('collection_id', '=', collectionId)
|
||||
.where('status', '=', 'accepted')
|
||||
.execute();
|
||||
return rows.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/** CL34 (`deleteCollection`'s snapshot, taken BEFORE the cascading delete) — `SELECT user_id FROM collection_members WHERE collection_id=? AND status='pending'`. */
|
||||
async pendingUserIds(collectionId: number): Promise<number[]> {
|
||||
const rows = await this.kysely<CollectionMemberUserIdsKyselyDB>()
|
||||
.selectFrom('collection_members')
|
||||
.select('user_id')
|
||||
.where('collection_id', '=', collectionId)
|
||||
.where('status', '=', 'pending')
|
||||
.execute();
|
||||
return rows.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/** `listCollections`'s incoming-invites read (see {@link CollectionIncomingInviteRow}'s docstring). */
|
||||
async pendingInvitesForUser(userId: number): Promise<CollectionIncomingInviteRow[]> {
|
||||
return await this.kysely<CollectionIncomingInvitesKyselyDB>()
|
||||
.selectFrom('collection_members as cm')
|
||||
.innerJoin('collections as c', 'c.id', 'cm.collection_id')
|
||||
.innerJoin('users as u', 'u.id', 'c.owner_id')
|
||||
.select(['cm.collection_id', 'c.name', 'u.id as from_id', 'u.username as from_username'])
|
||||
.where('cm.user_id', '=', userId)
|
||||
.where('cm.status', '=', 'pending')
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,504 @@
|
||||
import type { Collections } from '../entities/Collections.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class CollectionsRepository extends TrekRepository<Collections> {}
|
||||
/** `collections` — every scalar column, `getCollectionRow`'s (CL14) `SELECT *` shape. */
|
||||
export interface CollectionRow {
|
||||
id: number;
|
||||
owner_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
cover_image: string | null;
|
||||
links: string | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved place row, `cp.*` joined to its category name/color/icon — CL10
|
||||
* (`getPlaceById`, filtered by `cp.id`) and CL16/CL17 (`getCollection`/
|
||||
* `exportCollection`'s dup-text pair, filtered by `cp.collection_id`) both
|
||||
* select this exact shape. `links` stays the raw TEXT column (`string |
|
||||
* null`) here — never `CollectionLink[]` — the service's own `parseLinks`
|
||||
* does that parsing on the way out, the same reason `CollectionRow.links`
|
||||
* below is also raw TEXT: a hand-written row interface documents the DB
|
||||
* shape, not the wire shape (`_shared/rows.ts`'s docstring).
|
||||
*/
|
||||
export interface CollectionPlaceRow {
|
||||
id: number;
|
||||
collection_id: number;
|
||||
owner_id: number;
|
||||
saved_by: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
address: string | null;
|
||||
category_id: number | null;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
image_url: string | null;
|
||||
google_place_id: string | null;
|
||||
google_ftid: string | null;
|
||||
osm_id: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
status: string;
|
||||
source_trip_id: number | null;
|
||||
source_place_id: number | null;
|
||||
links: string | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
}
|
||||
|
||||
interface CollectionsKyselyDB {
|
||||
collections: CollectionRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* CL29's own `INSERT INTO collections (owner_id, name, description, color,
|
||||
* icon, cover_image, links, sort_order)` column list, exactly — no `id`/
|
||||
* `created_at`/`updated_at` (never bound; the schema's own
|
||||
* `DEFAULT CURRENT_TIMESTAMP`/autoincrement fill them), the
|
||||
* `BucketListRepository`/`BucketListInsertKyselyDB` precedent for a
|
||||
* narrower insert-only Kysely interface instead of `Generated<>` markers.
|
||||
*/
|
||||
interface CollectionsInsertKyselyDB {
|
||||
collections: {
|
||||
owner_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
cover_image: string | null;
|
||||
links: string | null;
|
||||
sort_order: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionIdsKyselyDB {
|
||||
collections: { id: number; owner_id: number };
|
||||
collection_members: { collection_id: number; user_id: number; status: string };
|
||||
}
|
||||
|
||||
interface CollectionPlacesReadKyselyDB {
|
||||
collection_places: Omit<CollectionPlaceRow, 'category_name' | 'category_color' | 'category_icon'>;
|
||||
categories: { id: number; name: string; color: string | null; icon: string | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* CL23's own `INSERT INTO collection_places (21 cols)` list, exactly — no
|
||||
* `id`/`created_at`/`updated_at`, the same narrower-insert-interface shape
|
||||
* as {@link CollectionsInsertKyselyDB} above.
|
||||
*/
|
||||
interface CollectionPlacesWriteKyselyDB {
|
||||
collection_places: {
|
||||
collection_id: number;
|
||||
owner_id: number;
|
||||
saved_by: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
address: string | null;
|
||||
category_id: number | null;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
image_url: string | null;
|
||||
google_place_id: string | null;
|
||||
google_ftid: string | null;
|
||||
osm_id: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
status: string;
|
||||
links: string | null;
|
||||
sort_order: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionPlaceLabelsKyselyDB {
|
||||
collection_place_labels: { collection_place_id: number; label_id: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* `collections` — the root of the collections cluster (Plan 3h Task 1, part
|
||||
* A). Owns every CL1-CL36 statement not moved to `CollectionMembersRepository`/
|
||||
* `CollectionLabelsRepository`: the `collections` table's own CRUD/reorder,
|
||||
* plus — as a pragmatic first cut, since `CollectionPlacesRepository`/
|
||||
* `CollectionPlaceTagsRepository`/`CollectionPlaceRatingsRepository` don't
|
||||
* exist yet (Task 2 builds them) — every hydration/export/import/dedup
|
||||
* statement Part A's own methods (`hydratePlaces`, `getPlaceById`,
|
||||
* `getCollection`/`exportCollection`, `writeFilePlaces`,
|
||||
* `findDuplicateCollectionPlace`) need against `collection_places`/
|
||||
* `collection_place_tags`/`collection_place_labels`/`collection_place_ratings`.
|
||||
* Task 2's own report should note whether any of these bridge methods move
|
||||
* onto its new, more specific repositories once they exist — see this
|
||||
* task's own report for the explicit flag.
|
||||
*/
|
||||
export class CollectionsRepository extends TrekRepository<Collections> {
|
||||
private readDb() {
|
||||
return this.kysely<CollectionsKyselyDB>();
|
||||
}
|
||||
|
||||
private insertDb() {
|
||||
return this.kysely<CollectionsInsertKyselyDB>();
|
||||
}
|
||||
|
||||
private idsDb() {
|
||||
return this.kysely<CollectionIdsKyselyDB>();
|
||||
}
|
||||
|
||||
private placesReadDb() {
|
||||
return this.kysely<CollectionPlacesReadKyselyDB>();
|
||||
}
|
||||
|
||||
private placesWriteDb() {
|
||||
return this.kysely<CollectionPlacesWriteKyselyDB>();
|
||||
}
|
||||
|
||||
private placeLabelsDb() {
|
||||
return this.kysely<CollectionPlaceLabelsKyselyDB>();
|
||||
}
|
||||
|
||||
/**
|
||||
* CL1 (`accessibleCollectionIds`) — `SELECT id FROM collections WHERE
|
||||
* owner_id=? UNION SELECT collection_id FROM collection_members WHERE
|
||||
* user_id=? AND status='accepted'`. Two typed selects, merged and
|
||||
* deduplicated in TS (`Set`) the same way SQL's `UNION` (not `UNION ALL`)
|
||||
* would — the legacy statement is `UNION`, so a row present in both halves
|
||||
* collapses to one id either way.
|
||||
*/
|
||||
async accessibleCollectionIds(userId: number): Promise<number[]> {
|
||||
const owned = await this.idsDb().selectFrom('collections').select('id').where('owner_id', '=', userId).execute();
|
||||
const member = await this.idsDb()
|
||||
.selectFrom('collection_members')
|
||||
.select('collection_id as id')
|
||||
.where('user_id', '=', userId)
|
||||
.where('status', '=', 'accepted')
|
||||
.execute();
|
||||
return [...new Set([...owned.map((r) => r.id), ...member.map((r) => r.id)])];
|
||||
}
|
||||
|
||||
/** CL5 (`ownerOf`) — `SELECT owner_id FROM collections WHERE id=?`. */
|
||||
async ownerId(id: number): Promise<number | undefined> {
|
||||
const row = await this.readDb().selectFrom('collections').select('owner_id').where('id', '=', id).executeTakeFirst();
|
||||
return row?.owner_id;
|
||||
}
|
||||
|
||||
/** CL14 (`getCollectionRow`) — `SELECT * FROM collections WHERE id=?`. */
|
||||
async findRow(id: number): Promise<CollectionRow | undefined> {
|
||||
return await this.readDb().selectFrom('collections').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
/** CL15 (`getCollectionRow`) — `SELECT COUNT(*) AS n FROM collection_places WHERE collection_id=?`. */
|
||||
async placeCount(collectionId: number): Promise<number> {
|
||||
const row = await this.placesReadDb()
|
||||
.selectFrom('collection_places')
|
||||
.select((eb) => eb.fn.countAll<number>().as('n'))
|
||||
.where('collection_id', '=', collectionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(row.n);
|
||||
}
|
||||
|
||||
/** CL28 (`createCollection`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collections WHERE owner_id=?`. */
|
||||
async maxSortOrder(ownerId: number): Promise<number> {
|
||||
const row = await this.readDb()
|
||||
.selectFrom('collections')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m'))
|
||||
.where('owner_id', '=', ownerId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(row.m);
|
||||
}
|
||||
|
||||
/**
|
||||
* CL29 (`createCollection`) — `INSERT INTO collections (owner_id, name,
|
||||
* description, color, icon, cover_image, links, sort_order) VALUES (...)`.
|
||||
* Returns the new row's id.
|
||||
*/
|
||||
async insertCollection(row: {
|
||||
owner_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
cover_image: string | null;
|
||||
links: string | null;
|
||||
sort_order: number;
|
||||
}): Promise<number> {
|
||||
const result = await this.insertDb().insertInto('collections').values(row).executeTakeFirstOrThrow();
|
||||
return Number(result.insertId);
|
||||
}
|
||||
|
||||
/**
|
||||
* CL30 (`updateCollection`) — the allow-listed dynamic `UPDATE collections
|
||||
* SET ... WHERE id=?`. `write` carries exactly the columns the caller
|
||||
* built (the service resolves `body.field !== undefined` itself, same
|
||||
* shape as every other repository's `presenceSet`-fed write) — an empty
|
||||
* `write` short-circuits before the `UPDATE`, matching the legacy
|
||||
* `if (updates.length > 0)` guard.
|
||||
*/
|
||||
async updateFields(
|
||||
id: number,
|
||||
write: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
cover_image: string | null;
|
||||
links: string | null;
|
||||
sort_order: number;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (Object.keys(write).length === 0) return;
|
||||
await this.readDb().updateTable('collections').set({ ...write, updated_at: new Date().toISOString() }).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
/** CL31 (`setCollectionCover`) — `SELECT cover_image FROM collections WHERE id=?`. */
|
||||
async coverImage(id: number): Promise<string | null | undefined> {
|
||||
const row = await this.readDb().selectFrom('collections').select('cover_image').where('id', '=', id).executeTakeFirst();
|
||||
return row?.cover_image;
|
||||
}
|
||||
|
||||
/** CL32 (`setCollectionCover`) — `UPDATE collections SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */
|
||||
async setCoverImage(id: number, coverUrl: string | null): Promise<void> {
|
||||
await this.readDb().updateTable('collections').set({ cover_image: coverUrl, updated_at: new Date().toISOString() }).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
/** CL35 (`deleteCollection`) — `DELETE FROM collections WHERE id=?` (CASCADE drops members + places + tags). */
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await this.readDb().deleteFrom('collections').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* CL36 (`reorderCollections`, TX `:712`) — `UPDATE collections SET
|
||||
* sort_order=? WHERE id=?`, looped per visible id. The legacy statement
|
||||
* is a `this.db.prepare(...)` reused across the loop (a single compiled
|
||||
* statement, `.run()` called once per iteration) — Kysely's query builder
|
||||
* has no equivalent "compile once, bind many" primitive for an UPDATE
|
||||
* with per-row values, so this issues one `updateTable` call per pair
|
||||
* instead (informational for Task 7: the "prepare once, loop `.run()`"
|
||||
* shape does NOT survive this conversion, per-call is what the repository
|
||||
* layer supports here).
|
||||
*/
|
||||
async setSortOrders(pairs: { id: number; sortOrder: number }[]): Promise<void> {
|
||||
const db = this.readDb();
|
||||
for (const { id, sortOrder } of pairs) {
|
||||
await db.updateTable('collections').set({ sort_order: sortOrder }).where('id', '=', id).execute();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hydration / export / import / dedup bridge methods — CL6, CL8-CL11,
|
||||
// CL16/17 (collapsed), CL22-CL27. First-cut home: these touch
|
||||
// `collection_places`/`collection_place_tags`/`collection_place_labels`/
|
||||
// `collection_place_ratings`, tables Task 2's own repositories will own
|
||||
// going forward, but Part A's `hydratePlaces`/`getPlaceById`/
|
||||
// `getCollection`/`exportCollection`/`writeFilePlaces`/
|
||||
// `findDuplicateCollectionPlace` are its own methods and need somewhere to
|
||||
// land now.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** CL10 (`getPlaceById`) — `SELECT cp.*, c.name/color/icon AS category_* FROM collection_places cp LEFT JOIN categories c ON cp.category_id=c.id WHERE cp.id=?`. */
|
||||
async findPlaceRowById(id: number): Promise<CollectionPlaceRow | undefined> {
|
||||
return await this.placesReadDb()
|
||||
.selectFrom('collection_places as cp')
|
||||
.leftJoin('categories as c', 'c.id', 'cp.category_id')
|
||||
.select([
|
||||
'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng',
|
||||
'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id',
|
||||
'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id',
|
||||
'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at',
|
||||
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
|
||||
])
|
||||
.where('cp.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* CL16/CL17 (`getCollection`/`exportCollection`'s dup-text pair, collapsed
|
||||
* onto ONE repository method as the brief directs) — the same
|
||||
* `cp.*`/category-alias select as {@link findPlaceRowById}, scoped by
|
||||
* `cp.collection_id` and ordered by `cp.sort_order, cp.created_at`.
|
||||
*/
|
||||
async listPlaceRows(collectionId: number): Promise<CollectionPlaceRow[]> {
|
||||
return await this.placesReadDb()
|
||||
.selectFrom('collection_places as cp')
|
||||
.leftJoin('categories as c', 'c.id', 'cp.category_id')
|
||||
.select([
|
||||
'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng',
|
||||
'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id',
|
||||
'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id',
|
||||
'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at',
|
||||
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
|
||||
])
|
||||
.where('cp.collection_id', '=', collectionId)
|
||||
.orderBy('cp.sort_order')
|
||||
.orderBy('cp.created_at')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL11 (`collectionIdOfPlace`) — `SELECT collection_id FROM collection_places WHERE id=?`. */
|
||||
async collectionIdOfPlace(placeId: number): Promise<number | undefined> {
|
||||
const row = await this.placesReadDb().selectFrom('collection_places').select('collection_id').where('id', '=', placeId).executeTakeFirst();
|
||||
return row?.collection_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* CL6 (`loadTagsByCollectionPlaceIds`) — `SELECT cpt.collection_place_id
|
||||
* AS pid, t.id, t.name, t.color FROM collection_place_tags cpt JOIN tags t
|
||||
* ON t.id=cpt.tag_id WHERE cpt.collection_place_id IN (...)`. Empty input
|
||||
* short-circuits to `[]` (no `IN ()` sent) — the service's own batched
|
||||
* hydration helper already guards this, but the repository guards too so
|
||||
* it is safe to call directly.
|
||||
*/
|
||||
async loadTagsByPlaceIds(placeIds: number[]): Promise<{ pid: number; id: number; name: string; color: string }[]> {
|
||||
if (placeIds.length === 0) return [];
|
||||
return await this.kysely<{ collection_place_tags: { collection_place_id: number; tag_id: number }; tags: { id: number; name: string; color: string } }>()
|
||||
.selectFrom('collection_place_tags as cpt')
|
||||
.innerJoin('tags as t', 't.id', 'cpt.tag_id')
|
||||
.select(['cpt.collection_place_id as pid', 't.id', 't.name', 't.color'])
|
||||
.where('cpt.collection_place_id', 'in', placeIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL8 (`loadLabelIdsByPlaceIds`) — `SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (...)`. */
|
||||
async loadLabelIdsByPlaceIds(placeIds: number[]): Promise<{ pid: number; label_id: number }[]> {
|
||||
if (placeIds.length === 0) return [];
|
||||
const rows = await this.placeLabelsDb()
|
||||
.selectFrom('collection_place_labels')
|
||||
.select(['collection_place_id as pid', 'label_id'])
|
||||
.where('collection_place_id', 'in', placeIds)
|
||||
.execute();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* CL9 (`loadRatingsByCollectionPlaceIds`, #1435) — `SELECT
|
||||
* cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar,
|
||||
* cpr.rating FROM collection_place_ratings cpr JOIN users u ON
|
||||
* u.id=cpr.user_id WHERE cpr.collection_place_id IN (...) ORDER BY
|
||||
* cpr.created_at`.
|
||||
*/
|
||||
async loadRatingsByPlaceIds(
|
||||
placeIds: number[],
|
||||
): Promise<{ pid: number; user_id: number; username: string; avatar: string | null; rating: number }[]> {
|
||||
if (placeIds.length === 0) return [];
|
||||
return await this.kysely<{
|
||||
collection_place_ratings: { collection_place_id: number; user_id: number; rating: number; created_at: string | null };
|
||||
users: { id: number; username: string; avatar: string | null };
|
||||
}>()
|
||||
.selectFrom('collection_place_ratings as cpr')
|
||||
.innerJoin('users as u', 'u.id', 'cpr.user_id')
|
||||
.select(['cpr.collection_place_id as pid', 'cpr.user_id', 'u.username', 'u.avatar', 'cpr.rating'])
|
||||
.where('cpr.collection_place_id', 'in', placeIds)
|
||||
.orderBy('cpr.created_at')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL22 (`writeFilePlaces`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collection_places WHERE collection_id=?`. */
|
||||
async maxPlaceSortOrder(collectionId: number): Promise<number> {
|
||||
const row = await this.placesReadDb()
|
||||
.selectFrom('collection_places')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m'))
|
||||
.where('collection_id', '=', collectionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(row.m);
|
||||
}
|
||||
|
||||
/**
|
||||
* CL23 (`writeFilePlaces`, PREPARED/looped in the legacy code) — `INSERT
|
||||
* INTO collection_places (21 cols incl. sort_order, no source_trip_id/
|
||||
* source_place_id) VALUES (...)`. Distinct insert shape from CL42/CL46
|
||||
* (Task 2's) — never collapsed with either. Returns the new row's id.
|
||||
*/
|
||||
async insertFilePlace(row: {
|
||||
collection_id: number;
|
||||
owner_id: number;
|
||||
saved_by: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
address: string | null;
|
||||
category_id: number | null;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
image_url: string | null;
|
||||
google_place_id: string | null;
|
||||
google_ftid: string | null;
|
||||
osm_id: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
status: string;
|
||||
links: string | null;
|
||||
sort_order: number;
|
||||
}): Promise<number> {
|
||||
const result = await this.placesWriteDb().insertInto('collection_places').values(row).executeTakeFirstOrThrow();
|
||||
return Number(result.insertId);
|
||||
}
|
||||
|
||||
/** CL24 (`writeFilePlaces`'s `assignLabel` prepared statement) — `INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?,?)`. */
|
||||
async assignPlaceLabel(collectionPlaceId: number, labelId: number): Promise<void> {
|
||||
await this.placeLabelsDb()
|
||||
.insertInto('collection_place_labels')
|
||||
.values({ collection_place_id: collectionPlaceId, label_id: labelId })
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** CL25 (`findDuplicateCollectionPlace`, externalId branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND (google_place_id=? OR google_ftid=? OR osm_id=?) ORDER BY id ASC LIMIT 1`. */
|
||||
async findDuplicateByExternalId(
|
||||
collectionId: number,
|
||||
externalId: string,
|
||||
): Promise<{ id: number; name: string } | undefined> {
|
||||
return await this.placesReadDb()
|
||||
.selectFrom('collection_places')
|
||||
.select(['id', 'name'])
|
||||
.where('collection_id', '=', collectionId)
|
||||
.where((eb) => eb.or([eb('google_place_id', '=', externalId), eb('google_ftid', '=', externalId), eb('osm_id', '=', externalId)]))
|
||||
.orderBy('id', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** CL26 (`findDuplicateCollectionPlace`, name branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lower(trim(name))=? ORDER BY id ASC LIMIT 1`. `normalizedName` is already `lower(trim(...))`-shaped by the caller (`_shared/../dialect/sql-functions.ts`'s `lowerTrim`/`lowerTrimParam` precedent). */
|
||||
async findDuplicateByName(collectionId: number, normalizedName: string): Promise<{ id: number; name: string } | undefined> {
|
||||
return await this.placesReadDb()
|
||||
.selectFrom('collection_places')
|
||||
.select(['id', 'name'])
|
||||
.where('collection_id', '=', collectionId)
|
||||
.where((eb) => eb(eb.fn('lower', [eb.fn('trim', ['name'])]), '=', normalizedName))
|
||||
.orderBy('id', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** CL27 (`findDuplicateCollectionPlace`, coord branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lat IS NOT NULL AND lng IS NOT NULL AND abs(lat-?)<=? AND abs(lng-?)<=? ORDER BY id ASC LIMIT 1`. */
|
||||
async findDuplicateByCoords(
|
||||
collectionId: number,
|
||||
lat: number,
|
||||
lng: number,
|
||||
tolerance: number,
|
||||
): Promise<{ id: number; name: string } | undefined> {
|
||||
return await this.placesReadDb()
|
||||
.selectFrom('collection_places')
|
||||
.select(['id', 'name'])
|
||||
.where('collection_id', '=', collectionId)
|
||||
.where('lat', 'is not', null)
|
||||
.where('lng', 'is not', null)
|
||||
.where((eb) => eb(eb.fn('abs', [eb('lat', '-', lat)]), '<=', tolerance))
|
||||
.where((eb) => eb(eb.fn('abs', [eb('lng', '-', lng)]), '<=', tolerance))
|
||||
.orderBy('id', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely';
|
||||
import type { CollectionMembers } from '../../entities/CollectionMembers.entity';
|
||||
import type { Collections } from '../../entities/Collections.entity';
|
||||
import type { TrekRepository } from './trek-repository';
|
||||
|
||||
/**
|
||||
* Plan 3h Task 1, R6 — the collections cluster's own membership/role model,
|
||||
* as ONE shared typed predicate instead of three ad hoc re-implementations.
|
||||
*
|
||||
* Legacy shape (`collections.service.ts` at HEAD, pre-conversion):
|
||||
* - `isVisible` (CL2) — `SELECT 1 FROM collections WHERE id=? AND
|
||||
* owner_id=? UNION SELECT 1 FROM collection_members WHERE
|
||||
* collection_id=? AND user_id=? AND status='accepted' LIMIT 1`.
|
||||
* - `isOwner` (CL3) — `SELECT 1 FROM collections WHERE id=? AND owner_id=?`.
|
||||
* - `roleOf` (CL4) — calls `isOwner` first (`'owner'` short-circuit), else
|
||||
* `SELECT role FROM collection_members WHERE collection_id=? AND
|
||||
* user_id=? AND status='accepted'`, normalizing an unrecognized/legacy
|
||||
* role value to `'editor'` (only `'admin'`/`'viewer'` pass through as-is).
|
||||
*
|
||||
* `roleOf`'s own legacy body ALREADY resolved `isVisible`/`isOwner` as its
|
||||
* own first branch — the three functions were never independent, only
|
||||
* independently re-queried. {@link resolveCollectionRole} is the SAME
|
||||
* two-step resolution (owner match, else an accepted member row's role),
|
||||
* built once here: `isVisible` becomes `role !== null`, `isOwner` becomes
|
||||
* `role === 'owner'`, `roleOf` returns the role directly.
|
||||
* `assertAccess`/`assertCanEdit`/`assertCanDelete` stay service-level
|
||||
* orchestration (CollectionsService) — they call this predicate and branch
|
||||
* on their own three distinct refusal shapes (404 no-access, 403-viewer
|
||||
* edit-refused, 403-non-admin delete-refused); this file has no knowledge
|
||||
* of HTTP status codes.
|
||||
*
|
||||
* **QB-callable form** ({@link resolveCollectionRole}): takes the caller's
|
||||
* own `CollectionsRepository`/`CollectionMembersRepository` (both extend
|
||||
* `TrekRepository`, typed generically here so this file stays independent
|
||||
* of either repository class — the same reason `_shared/owned-lookup.ts`
|
||||
* types its helpers against `TrekRepository<T>` rather than a concrete
|
||||
* repository) and resolves the role via two ordinary `findOne` calls —
|
||||
* MikroORM's own QueryBuilder-backed API, the "repository methods that need
|
||||
* it inline" form R6 asks for. This is what `CollectionsService`'s
|
||||
* `isVisible`/`isOwner`/`roleOf` call directly; Task 2's own
|
||||
* `assertCanEdit`/`assertCanDelete` call sites throughout the saved-places/
|
||||
* copy-to-trip/labels/invites surface go through the SAME service methods,
|
||||
* never re-deriving the predicate.
|
||||
*
|
||||
* **Kysely-callable form** ({@link collectionVisibleExpr}): an inline
|
||||
* boolean `EXISTS`/`OR` predicate for a caller already building a raw,
|
||||
* multi-join statement through `this.kysely()` (the shape Task 2's dynamic
|
||||
* OR-condition methods — `matchingCollectionPlaces`/`findMembership`,
|
||||
* CL58/CL69 — are the most likely future consumers of), modelled on
|
||||
* `packing-visibility.ts`'s `packingVisibleToActorExpr`: an owner-id equality
|
||||
* plus a correlated `EXISTS` against `collection_members`, mirroring CL2's
|
||||
* own UNION shape as a boolean OR instead of a value-returning UNION SELECT.
|
||||
* This form answers "is this collection visible to this user", not "what is
|
||||
* their role" — a caller needing the actual role (for an edit/delete gate)
|
||||
* always uses {@link resolveCollectionRole}, never this boolean form.
|
||||
*
|
||||
* Proven against five actor cases (owner, admin member, editor member,
|
||||
* viewer member, an invited-but-still-pending user, a complete stranger) by
|
||||
* `tests/unit/db/repositories/_shared/collection-role.parity.test.ts`,
|
||||
* mutation-proved (flip one branch, confirm the matching case goes red) —
|
||||
* Task 2 imports that harness rather than re-deriving it.
|
||||
*/
|
||||
export type CollectionRole = 'owner' | 'admin' | 'editor' | 'viewer' | null;
|
||||
|
||||
/**
|
||||
* The two-step role resolution CL2/CL3/CL4 all reduce to: an owner match
|
||||
* short-circuits to `'owner'`; otherwise an accepted member row's own
|
||||
* `role` column is read and normalized (`'admin'`/`'viewer'` pass through,
|
||||
* anything else — including a future/unknown value — becomes `'editor'`,
|
||||
* the legacy `roleOf`'s own fallback); no row of either kind resolves to
|
||||
* `null` (no access).
|
||||
*
|
||||
* `fields: ['id']`/`fields: ['role']` — a narrow projection, like every
|
||||
* other "rows out, existence/value only" read in this program
|
||||
* (`findOwnedByUser`'s own docstring).
|
||||
*/
|
||||
export async function resolveCollectionRole(
|
||||
collections: TrekRepository<Collections>,
|
||||
members: TrekRepository<CollectionMembers>,
|
||||
collectionId: number,
|
||||
userId: number,
|
||||
): Promise<CollectionRole> {
|
||||
const owner = await collections.findOne({ id: collectionId, owner: userId }, { fields: ['id'] });
|
||||
if (owner) return 'owner';
|
||||
const member = await members.findOne(
|
||||
{ collection: collectionId, user: userId, status: 'accepted' },
|
||||
{ fields: ['role'] },
|
||||
);
|
||||
if (!member) return null;
|
||||
return member.role === 'admin' || member.role === 'viewer' ? (member.role as 'admin' | 'viewer') : 'editor';
|
||||
}
|
||||
|
||||
/**
|
||||
* `collections`/`collection_members`'s REAL table names, the shape
|
||||
* {@link collectionVisibleExpr} needs. A consumer's own local Kysely `DB`
|
||||
* interface satisfies this by structural extension, the
|
||||
* `ReservationVisibilityKyselyDB`/`PackingVisibilityKyselyDB` precedent.
|
||||
*/
|
||||
export interface CollectionVisibilityKyselyDB {
|
||||
collections: { id: number; owner_id: number };
|
||||
collection_members: { collection_id: number; user_id: number; status: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of CL2's UNION, as a boolean OR: owner-id
|
||||
* equality, or a correlated `EXISTS` against an accepted `collection_members`
|
||||
* row. `collections` is a genuine `p.manyToOne(Users)` FK
|
||||
* (`Collections.entity.ts`), but this form takes the raw `owner_id` column
|
||||
* directly rather than joining through the relation — matching CL2's own
|
||||
* `owner_id = ?` text, and avoiding a second join the caller's own outer
|
||||
* query may not want.
|
||||
*/
|
||||
export function collectionVisibleExpr(
|
||||
eb: ExpressionBuilder<CollectionVisibilityKyselyDB, 'collections'>,
|
||||
userId: number,
|
||||
): ExpressionWrapper<CollectionVisibilityKyselyDB, 'collections', SqlBool> {
|
||||
return eb.or([
|
||||
eb('collections.owner_id', '=', userId),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('collection_members as cm')
|
||||
.select('cm.collection_id')
|
||||
.whereRef('cm.collection_id', '=', 'collections.id')
|
||||
.where('cm.user_id', '=', userId)
|
||||
.where('cm.status', '=', 'accepted'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
demoDenied, ok,
|
||||
} from '../../nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { CollectionsService } from './collections.service';
|
||||
import {
|
||||
collectionCreateRequestSchema, collectionUpdateRequestSchema,
|
||||
@@ -57,7 +59,7 @@ const collectionsAddonOn = addonGate(ADDON_IDS.COLLECTIONS);
|
||||
export class CollectionsMcp {
|
||||
constructor(
|
||||
private readonly collections: CollectionsService,
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Users) private readonly users: UsersRepository,
|
||||
private readonly auth: AuthService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
@@ -379,7 +381,7 @@ export class CollectionsMcp {
|
||||
// try/catch added with the post-fold quirk pass — the legacy handler was one
|
||||
// of two where an unexpected throw escaped to the SDK instead of isError.
|
||||
try {
|
||||
const me = this.db.get<{ username: string; email: string }>('SELECT username, email FROM users WHERE id = ?', ctx.userId);
|
||||
const me = await this.users.findUsernameEmail(ctx.userId);
|
||||
const res = await this.collections.sendInvite(collection_id, ctx.userId, me?.username ?? '', me?.email ?? '', user_id, role);
|
||||
if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true };
|
||||
return ok({ success: true });
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Collections } from '../../db/entities/Collections.entity';
|
||||
import { CollectionMembers } from '../../db/entities/CollectionMembers.entity';
|
||||
import { CollectionLabels } from '../../db/entities/CollectionLabels.entity';
|
||||
import { Categories } from '../../db/entities/Categories.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { CollectionsController } from './collections.controller';
|
||||
import { CollectionsService } from './collections.service';
|
||||
import { CollectionsRpc } from './collections.rpc';
|
||||
@@ -35,7 +41,8 @@ import { MAX_COVER_SIZE } from './collections.controller';
|
||||
}),
|
||||
}),
|
||||
StorageModule,
|
||||
NotificationsModule, AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule],
|
||||
NotificationsModule, AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule,
|
||||
MikroOrmModule.forFeature([Collections, CollectionMembers, CollectionLabels, Categories, Users])],
|
||||
controllers: [CollectionsController],
|
||||
providers: [CollectionsService, CollectionsMcp, CollectionsRpc],
|
||||
exports: [CollectionsService],
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import path from 'path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { reclaimPlaceImage } from '../places/place-image';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { Collections } from '../../db/entities/Collections.entity';
|
||||
import { CollectionsRepository, type CollectionPlaceRow } from '../../db/repositories/Collections.repository';
|
||||
import { CollectionMembers } from '../../db/entities/CollectionMembers.entity';
|
||||
import { CollectionMembersRepository } from '../../db/repositories/CollectionMembers.repository';
|
||||
import { CollectionLabels } from '../../db/entities/CollectionLabels.entity';
|
||||
import { CollectionLabelsRepository } from '../../db/repositories/CollectionLabels.repository';
|
||||
import { Categories } from '../../db/entities/Categories.entity';
|
||||
import type { CategoriesRepository } from '../../db/repositories/Categories.repository';
|
||||
import { resolveCollectionRole, type CollectionRole } from '../../db/repositories/_shared/collection-role';
|
||||
import {
|
||||
COORD_DEDUP_TOLERANCE,
|
||||
externalIdsOf,
|
||||
@@ -78,13 +88,12 @@ function httpError(status: number, message: string): never {
|
||||
throw err;
|
||||
}
|
||||
|
||||
export type EffectiveRole = 'owner' | 'admin' | 'editor' | 'viewer' | null;
|
||||
/** Re-exported under this service's historical name — the underlying type
|
||||
* now lives in `_shared/collection-role.ts` (R6), the ONE shared predicate
|
||||
* `isVisible`/`isOwner`/`roleOf` all resolve through. */
|
||||
export type EffectiveRole = CollectionRole;
|
||||
|
||||
interface PlaceRow extends CollectionPlace {
|
||||
category_name?: string | null;
|
||||
category_color?: string | null;
|
||||
category_icon?: string | null;
|
||||
}
|
||||
type PlaceRow = CollectionPlaceRow;
|
||||
|
||||
const MAX_LABELS_PER_COLLECTION = 50;
|
||||
|
||||
@@ -106,6 +115,10 @@ export class CollectionsService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(Collections) private readonly collectionsRepo: CollectionsRepository,
|
||||
@InjectRepository(CollectionMembers) private readonly members: CollectionMembersRepository,
|
||||
@InjectRepository(CollectionLabels) private readonly labels: CollectionLabelsRepository,
|
||||
@InjectRepository(Categories) private readonly categories: CategoriesRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -129,22 +142,12 @@ export class CollectionsService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async accessibleCollectionIds(userId: number): Promise<number[]> {
|
||||
const rows = this.db.all<{ id: number }>(`
|
||||
SELECT id FROM collections WHERE owner_id = ?
|
||||
UNION
|
||||
SELECT collection_id FROM collection_members WHERE user_id = ? AND status = 'accepted'
|
||||
`, userId, userId);
|
||||
return rows.map(r => r.id);
|
||||
return this.collectionsRepo.accessibleCollectionIds(userId);
|
||||
}
|
||||
|
||||
/** R6 — CL2/CL3/CL4 collapse onto the ONE shared predicate in `_shared/collection-role.ts`. */
|
||||
private async isVisible(userId: number, collectionId: number): Promise<boolean> {
|
||||
const row = this.db.get(`
|
||||
SELECT 1 FROM collections WHERE id = ? AND owner_id = ?
|
||||
UNION
|
||||
SELECT 1 FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'
|
||||
LIMIT 1
|
||||
`, collectionId, userId, collectionId, userId);
|
||||
return !!row;
|
||||
return (await resolveCollectionRole(this.collectionsRepo, this.members, collectionId, userId)) !== null;
|
||||
}
|
||||
|
||||
async assertAccess(userId: number, collectionId: number): Promise<void> {
|
||||
@@ -152,20 +155,13 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
async isOwner(userId: number, collectionId: number): Promise<boolean> {
|
||||
const row = this.db.get('SELECT 1 FROM collections WHERE id = ? AND owner_id = ?', collectionId, userId);
|
||||
return !!row;
|
||||
return (await resolveCollectionRole(this.collectionsRepo, this.members, collectionId, userId)) === 'owner';
|
||||
}
|
||||
|
||||
/** The viewer's effective permission on a list: owner (full), or their accepted
|
||||
* member role, or null when they have no access. */
|
||||
async roleOf(userId: number, collectionId: number): Promise<EffectiveRole> {
|
||||
if (await this.isOwner(userId, collectionId)) return 'owner';
|
||||
const row = this.db.get<{ role: string }>(
|
||||
"SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'",
|
||||
collectionId, userId,
|
||||
);
|
||||
if (!row) return null;
|
||||
return row.role === 'admin' || row.role === 'viewer' ? row.role : 'editor';
|
||||
return resolveCollectionRole(this.collectionsRepo, this.members, collectionId, userId);
|
||||
}
|
||||
|
||||
/** Add/edit a place — owner, admin or editor. 404 hides lists you can't see,
|
||||
@@ -184,9 +180,9 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
private async ownerOf(collectionId: number): Promise<number> {
|
||||
const row = this.db.get<{ owner_id: number }>('SELECT owner_id FROM collections WHERE id = ?', collectionId);
|
||||
if (!row) httpError(404, 'Collection not found');
|
||||
return row.owner_id;
|
||||
const ownerId = await this.collectionsRepo.ownerId(collectionId);
|
||||
if (ownerId === undefined) httpError(404, 'Collection not found');
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -195,14 +191,7 @@ export class CollectionsService {
|
||||
|
||||
private async loadTagsByCollectionPlaceIds(placeIds: number[]): Promise<Record<number, { id: number; name: string; color: string }[]>> {
|
||||
const out: Record<number, { id: number; name: string; color: string }[]> = {};
|
||||
if (placeIds.length === 0) return out;
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const rows = this.db.all<{ pid: number; id: number; name: string; color: string }>(`
|
||||
SELECT cpt.collection_place_id AS pid, t.id, t.name, t.color
|
||||
FROM collection_place_tags cpt
|
||||
JOIN tags t ON t.id = cpt.tag_id
|
||||
WHERE cpt.collection_place_id IN (${placeholders})
|
||||
`, ...placeIds);
|
||||
const rows = await this.collectionsRepo.loadTagsByPlaceIds(placeIds);
|
||||
for (const r of rows) {
|
||||
if (!out[r.pid]) out[r.pid] = [];
|
||||
out[r.pid].push({ id: r.id, name: r.name, color: r.color });
|
||||
@@ -212,21 +201,13 @@ export class CollectionsService {
|
||||
|
||||
/** A list's own label definitions, in display order. */
|
||||
private async loadLabelsByCollection(collectionId: number): Promise<CollectionLabel[]> {
|
||||
return this.db.all<CollectionLabel>(
|
||||
'SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id = ? ORDER BY sort_order, id',
|
||||
collectionId,
|
||||
);
|
||||
return this.labels.listByCollection(collectionId);
|
||||
}
|
||||
|
||||
/** Assigned label ids per place, batched (mirrors loadTagsByCollectionPlaceIds). */
|
||||
private async loadLabelIdsByPlaceIds(placeIds: number[]): Promise<Record<number, number[]>> {
|
||||
const out: Record<number, number[]> = {};
|
||||
if (placeIds.length === 0) return out;
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const rows = this.db.all<{ pid: number; label_id: number }>(
|
||||
`SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (${placeholders})`,
|
||||
...placeIds,
|
||||
);
|
||||
const rows = await this.collectionsRepo.loadLabelIdsByPlaceIds(placeIds);
|
||||
for (const r of rows) {
|
||||
if (!out[r.pid]) out[r.pid] = [];
|
||||
out[r.pid].push(r.label_id);
|
||||
@@ -237,14 +218,7 @@ export class CollectionsService {
|
||||
/** Per-voter rating rows (#1435), batched (mirrors loadTagsByCollectionPlaceIds). */
|
||||
private async loadRatingsByCollectionPlaceIds(placeIds: number[]): Promise<Record<number, { user_id: number; username: string; avatar: string | null; rating: number }[]>> {
|
||||
const out: Record<number, { user_id: number; username: string; avatar: string | null; rating: number }[]> = {};
|
||||
if (placeIds.length === 0) return out;
|
||||
const rows = this.db.all<{ pid: number; user_id: number; username: string; avatar: string | null; rating: number }>(`
|
||||
SELECT cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar, cpr.rating
|
||||
FROM collection_place_ratings cpr
|
||||
JOIN users u ON u.id = cpr.user_id
|
||||
WHERE cpr.collection_place_id IN (${placeIds.map(() => '?').join(',')})
|
||||
ORDER BY cpr.created_at
|
||||
`, ...placeIds);
|
||||
const rows = await this.collectionsRepo.loadRatingsByPlaceIds(placeIds);
|
||||
for (const { pid, ...rest } of rows) {
|
||||
if (!out[pid]) out[pid] = [];
|
||||
out[pid].push(rest);
|
||||
@@ -276,45 +250,31 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
private async getPlaceById(placeId: number): Promise<CollectionPlace> {
|
||||
const row = this.db.get<PlaceRow>(`
|
||||
SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon
|
||||
FROM collection_places cp
|
||||
LEFT JOIN categories c ON cp.category_id = c.id
|
||||
WHERE cp.id = ?
|
||||
`, placeId);
|
||||
const row = await this.collectionsRepo.findPlaceRowById(placeId);
|
||||
if (!row) httpError(404, 'Place not found');
|
||||
return (await this.hydratePlaces([row]))[0];
|
||||
}
|
||||
|
||||
private async collectionIdOfPlace(placeId: number): Promise<number> {
|
||||
const row = this.db.get<{ collection_id: number }>('SELECT collection_id FROM collection_places WHERE id = ?', placeId);
|
||||
if (!row) httpError(404, 'Place not found');
|
||||
return row.collection_id;
|
||||
const collectionId = await this.collectionsRepo.collectionIdOfPlace(placeId);
|
||||
if (collectionId === undefined) httpError(404, 'Place not found');
|
||||
return collectionId;
|
||||
}
|
||||
|
||||
private async buildMembers(collectionId: number): Promise<CollectionMember[]> {
|
||||
const owner = this.db.get<Omit<CollectionMember, 'status' | 'is_owner'>>(`
|
||||
SELECT u.id AS user_id, u.username, u.email, u.avatar
|
||||
FROM collections col JOIN users u ON u.id = col.owner_id
|
||||
WHERE col.id = ?
|
||||
`, collectionId);
|
||||
const members = this.db.all<Omit<CollectionMember, 'is_owner'>>(`
|
||||
SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role
|
||||
FROM collection_members cm JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.collection_id = ?
|
||||
ORDER BY u.username
|
||||
`, collectionId);
|
||||
const owner = await this.members.ownerRow(collectionId);
|
||||
const memberRows = await this.members.memberRows(collectionId);
|
||||
const result: CollectionMember[] = [];
|
||||
if (owner) result.push({ ...owner, status: 'accepted', role: 'admin', is_owner: true });
|
||||
for (const m of members) result.push({ ...m, is_owner: false });
|
||||
for (const m of memberRows) result.push({ ...m, is_owner: false } as CollectionMember);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getCollectionRow(id: number): Promise<Collection> {
|
||||
const col = this.db.get<Collection & { links?: unknown }>('SELECT * FROM collections WHERE id = ?', id);
|
||||
const col = await this.collectionsRepo.findRow(id);
|
||||
if (!col) httpError(404, 'Collection not found');
|
||||
const placeCount = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?', id)!.n;
|
||||
return { ...col, links: parseLinks(col.links), place_count: placeCount, members: await this.buildMembers(id) };
|
||||
const placeCount = await this.collectionsRepo.placeCount(id);
|
||||
return { ...col, links: parseLinks(col.links), place_count: placeCount, members: await this.buildMembers(id) } as Collection;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -330,13 +290,7 @@ export class CollectionsService {
|
||||
})))
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id);
|
||||
|
||||
const incomingInvites = this.db.all<{ collection_id: number; name: string; from_id: number; from_username: string }>(`
|
||||
SELECT cm.collection_id, c.name, u.id AS from_id, u.username AS from_username
|
||||
FROM collection_members cm
|
||||
JOIN collections c ON c.id = cm.collection_id
|
||||
JOIN users u ON u.id = c.owner_id
|
||||
WHERE cm.user_id = ? AND cm.status = 'pending'
|
||||
`, userId)
|
||||
const incomingInvites = (await this.members.pendingInvitesForUser(userId))
|
||||
.map(r => ({ collection_id: r.collection_id, name: r.name, from: { id: r.from_id, username: r.from_username } }));
|
||||
|
||||
return { collections, incomingInvites };
|
||||
@@ -345,13 +299,7 @@ export class CollectionsService {
|
||||
async getCollection(userId: number, id: number): Promise<CollectionDetailResponse> {
|
||||
await this.assertAccess(userId, id);
|
||||
const collection = await this.getCollectionRow(id);
|
||||
const rows = this.db.all<PlaceRow>(`
|
||||
SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon
|
||||
FROM collection_places cp
|
||||
LEFT JOIN categories c ON cp.category_id = c.id
|
||||
WHERE cp.collection_id = ?
|
||||
ORDER BY cp.sort_order, cp.created_at
|
||||
`, id);
|
||||
const rows = await this.collectionsRepo.listPlaceRows(id);
|
||||
return {
|
||||
collection: { ...collection, is_owner: collection.owner_id === userId, labels: await this.loadLabelsByCollection(id) },
|
||||
places: await this.hydratePlaces(rows),
|
||||
@@ -381,13 +329,7 @@ export class CollectionsService {
|
||||
const labels = await this.loadLabelsByCollection(id);
|
||||
const labelNameById = new Map(labels.map(l => [l.id, l.name]));
|
||||
|
||||
const rows = this.db.all<PlaceRow>(`
|
||||
SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon
|
||||
FROM collection_places cp
|
||||
LEFT JOIN categories c ON cp.category_id = c.id
|
||||
WHERE cp.collection_id = ?
|
||||
ORDER BY cp.sort_order, cp.created_at
|
||||
`, id);
|
||||
const rows = await this.collectionsRepo.listPlaceRows(id);
|
||||
const labelIdsByPlace = await this.loadLabelIdsByPlaceIds(rows.map(r => r.id));
|
||||
|
||||
const places: CollectionFilePlace[] = rows.map(row => ({
|
||||
@@ -407,7 +349,7 @@ export class CollectionsService {
|
||||
google_place_id: row.google_place_id ?? null,
|
||||
google_ftid: row.google_ftid ?? null,
|
||||
osm_id: row.osm_id ?? null,
|
||||
status: row.status,
|
||||
status: row.status as CollectionStatus,
|
||||
links: parseLinks((row as { links?: unknown }).links),
|
||||
category: row.category_name ?? null,
|
||||
labels: (labelIdsByPlace[row.id] || []).map(lid => labelNameById.get(lid)).filter((n): n is string => !!n),
|
||||
@@ -530,12 +472,10 @@ export class CollectionsService {
|
||||
collectionId: number, labels: CollectionFileLabel[] | undefined,
|
||||
): Promise<{ byName: Map<string, number>; created: number }> {
|
||||
const byName = new Map<string, number>();
|
||||
for (const row of this.db.all<{ id: number; name: string }>('SELECT id, name FROM collection_labels WHERE collection_id = ?', collectionId)) {
|
||||
for (const row of await this.labels.idNameByCollection(collectionId)) {
|
||||
byName.set(row.name.trim().toLowerCase(), row.id);
|
||||
}
|
||||
let sortOrder = this.db.get<{ m: number }>(
|
||||
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?', collectionId,
|
||||
)!.m + 1;
|
||||
let sortOrder = (await this.labels.maxSortOrder(collectionId)) + 1;
|
||||
let created = 0;
|
||||
for (const label of (labels ?? []).slice(0, MAX_COLLECTION_FILE_LABELS)) {
|
||||
const key = label.name.trim().toLowerCase();
|
||||
@@ -570,22 +510,11 @@ export class CollectionsService {
|
||||
// The palette is instance-wide and read-only here: a file names a category,
|
||||
// it does not get to create one.
|
||||
const categoryIdByName = new Map<string, number>();
|
||||
for (const c of this.db.all<{ id: number; name: string }>('SELECT id, name FROM categories')) {
|
||||
for (const c of await this.categories.listIdName()) {
|
||||
categoryIdByName.set(c.name.trim().toLowerCase(), c.id);
|
||||
}
|
||||
const ownerId = await this.ownerOf(collectionId);
|
||||
const firstOrder = this.db.get<{ m: number }>(
|
||||
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_places WHERE collection_id = ?', collectionId,
|
||||
)!.m + 1;
|
||||
|
||||
const insertPlace = this.db.prepare(`
|
||||
INSERT INTO collection_places (
|
||||
collection_id, owner_id, saved_by, name, description, lat, lng, address,
|
||||
category_id, price, currency, notes, image_url, google_place_id, google_ftid,
|
||||
osm_id, website, phone, status, links, sort_order
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const assignLabel = this.db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
|
||||
const firstOrder = (await this.collectionsRepo.maxPlaceSortOrder(collectionId)) + 1;
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -607,19 +536,18 @@ export class CollectionsService {
|
||||
duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
const res = insertPlace.run(
|
||||
collectionId, ownerId, savedBy,
|
||||
place.name, place.description ?? null, place.lat ?? null, place.lng ?? null, place.address ?? null,
|
||||
place.category ? (categoryIdByName.get(place.category.trim().toLowerCase()) ?? null) : null,
|
||||
place.price ?? null, place.currency ?? null, place.notes ?? null,
|
||||
place.image_url ?? null, place.google_place_id ?? null, place.google_ftid ?? null,
|
||||
place.osm_id ?? null, place.website ?? null, place.phone ?? null,
|
||||
place.status ?? 'idea', serializeLinks(place.links), firstOrder + imported,
|
||||
);
|
||||
const placeId = Number(res.lastInsertRowid);
|
||||
const placeId = await this.collectionsRepo.insertFilePlace({
|
||||
collection_id: collectionId, owner_id: ownerId, saved_by: savedBy,
|
||||
name: place.name, description: place.description ?? null, lat: place.lat ?? null, lng: place.lng ?? null, address: place.address ?? null,
|
||||
category_id: place.category ? (categoryIdByName.get(place.category.trim().toLowerCase()) ?? null) : null,
|
||||
price: place.price ?? null, currency: place.currency ?? null, notes: place.notes ?? null,
|
||||
image_url: place.image_url ?? null, google_place_id: place.google_place_id ?? null, google_ftid: place.google_ftid ?? null,
|
||||
osm_id: place.osm_id ?? null, website: place.website ?? null, phone: place.phone ?? null,
|
||||
status: place.status ?? 'idea', links: serializeLinks(place.links), sort_order: firstOrder + imported,
|
||||
});
|
||||
for (const labelName of place.labels ?? []) {
|
||||
const labelId = labelIdByName.get(labelName.trim().toLowerCase());
|
||||
if (labelId) assignLabel.run(placeId, labelId);
|
||||
if (labelId) await this.collectionsRepo.assignPlaceLabel(placeId, labelId);
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
@@ -633,48 +561,38 @@ export class CollectionsService {
|
||||
* import is sent by the caller rather than one per label.
|
||||
*/
|
||||
private async insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): Promise<number> {
|
||||
const res = this.db.run(
|
||||
'INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)',
|
||||
collectionId, label.name.trim(), label.color ?? '#6366f1', sortOrder,
|
||||
);
|
||||
return Number(res.lastInsertRowid);
|
||||
return this.labels.insertLabel({
|
||||
collection_id: collectionId, name: label.name.trim(), color: label.color ?? '#6366f1', sort_order: sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
async createCollection(userId: number, body: CollectionCreateRequest): Promise<Collection> {
|
||||
const max = this.db.get<{ m: number }>('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?', userId)!.m;
|
||||
const result = this.db.run(`
|
||||
INSERT INTO collections (owner_id, name, description, color, icon, cover_image, links, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
userId,
|
||||
body.name,
|
||||
body.description ?? null,
|
||||
body.color ?? '#6366f1',
|
||||
body.icon ?? 'Bookmark',
|
||||
body.cover_image ?? null,
|
||||
serializeLinks(body.links),
|
||||
max + 1,
|
||||
);
|
||||
const col = await this.getCollectionRow(Number(result.lastInsertRowid));
|
||||
const max = await this.collectionsRepo.maxSortOrder(userId);
|
||||
const id = await this.collectionsRepo.insertCollection({
|
||||
owner_id: userId,
|
||||
name: body.name,
|
||||
description: body.description ?? null,
|
||||
color: body.color ?? '#6366f1',
|
||||
icon: body.icon ?? 'Bookmark',
|
||||
cover_image: body.cover_image ?? null,
|
||||
links: serializeLinks(body.links),
|
||||
sort_order: max + 1,
|
||||
});
|
||||
const col = await this.getCollectionRow(id);
|
||||
return { ...col, is_owner: true };
|
||||
}
|
||||
|
||||
async updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Promise<Collection> {
|
||||
await this.assertCanEdit(userId, id);
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); }
|
||||
if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description ?? null); }
|
||||
if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color ?? null); }
|
||||
if (body.icon !== undefined) { updates.push('icon = ?'); params.push(body.icon ?? null); }
|
||||
if (body.cover_image !== undefined) { updates.push('cover_image = ?'); params.push(body.cover_image ?? null); }
|
||||
if (body.links !== undefined) { updates.push('links = ?'); params.push(serializeLinks(body.links)); }
|
||||
if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); }
|
||||
if (updates.length > 0) {
|
||||
updates.push("updated_at = CURRENT_TIMESTAMP");
|
||||
params.push(id);
|
||||
this.db.run(`UPDATE collections SET ${updates.join(', ')} WHERE id = ?`, ...params);
|
||||
}
|
||||
await this.collectionsRepo.updateFields(id, {
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||
...(body.color !== undefined ? { color: body.color ?? null } : {}),
|
||||
...(body.icon !== undefined ? { icon: body.icon ?? null } : {}),
|
||||
...(body.cover_image !== undefined ? { cover_image: body.cover_image ?? null } : {}),
|
||||
...(body.links !== undefined ? { links: serializeLinks(body.links) } : {}),
|
||||
...(body.sort_order !== undefined ? { sort_order: body.sort_order } : {}),
|
||||
});
|
||||
await this.notifyCollectionUsers(id, socketId, 'collections:updated');
|
||||
const col = await this.getCollectionRow(id);
|
||||
return { ...col, is_owner: col.owner_id === userId };
|
||||
@@ -683,8 +601,8 @@ export class CollectionsService {
|
||||
/** Set (or clear) a list's cover image, reclaiming the previous file. */
|
||||
async setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): Promise<Collection> {
|
||||
await this.assertCanEdit(userId, id);
|
||||
const prev = this.db.get<{ cover_image: string | null }>('SELECT cover_image FROM collections WHERE id = ?', id)?.cover_image ?? null;
|
||||
this.db.run('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', coverUrl, id);
|
||||
const prev = (await this.collectionsRepo.coverImage(id)) ?? null;
|
||||
await this.collectionsRepo.setCoverImage(id, coverUrl);
|
||||
if (prev && prev !== coverUrl) await this.deleteOldCollectionCover(prev);
|
||||
await this.notifyCollectionUsers(id, socketId, 'collections:updated');
|
||||
const col = await this.getCollectionRow(id);
|
||||
@@ -696,10 +614,10 @@ export class CollectionsService {
|
||||
if (!(await this.isOwner(userId, id))) httpError(403, 'Only the owner can delete this list');
|
||||
|
||||
// Snapshot recipients BEFORE the cascade wipes collection_members.
|
||||
const accepted = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'", id).map(r => r.user_id);
|
||||
const pending = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'pending'", id).map(r => r.user_id);
|
||||
const accepted = await this.members.acceptedUserIds(id);
|
||||
const pending = await this.members.pendingUserIds(id);
|
||||
|
||||
this.db.run('DELETE FROM collections WHERE id = ?', id); // CASCADE drops members + places + tags
|
||||
await this.collectionsRepo.deleteById(id); // CASCADE drops members + places + tags
|
||||
|
||||
[...new Set([...accepted, ...pending])]
|
||||
.filter(uid => uid !== userId)
|
||||
@@ -708,11 +626,12 @@ export class CollectionsService {
|
||||
|
||||
async reorderCollections(userId: number, orderedIds: number[]): Promise<void> {
|
||||
const visible = new Set(await this.accessibleCollectionIds(userId));
|
||||
const stmt = this.db.prepare('UPDATE collections SET sort_order = ? WHERE id = ?');
|
||||
await this.uow.transactional(async () => {
|
||||
orderedIds.forEach((cid, index) => {
|
||||
if (visible.has(cid)) stmt.run(index, cid);
|
||||
});
|
||||
await this.collectionsRepo.setSortOrders(
|
||||
orderedIds
|
||||
.map((cid, index) => ({ id: cid, sortOrder: index }))
|
||||
.filter(({ id }) => visible.has(id)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -742,24 +661,11 @@ export class CollectionsService {
|
||||
for (const strategy of placeMatchStrategies(candidate)) {
|
||||
let hit: { id: number; name: string } | undefined;
|
||||
if (strategy.by === 'externalId') {
|
||||
hit = this.db.get<{ id: number; name: string }>(`
|
||||
SELECT id, name FROM collection_places
|
||||
WHERE collection_id = ? AND (google_place_id = ? OR google_ftid = ? OR osm_id = ?)
|
||||
ORDER BY id ASC LIMIT 1
|
||||
`, collectionId, strategy.id, strategy.id, strategy.id);
|
||||
hit = await this.collectionsRepo.findDuplicateByExternalId(collectionId, strategy.id);
|
||||
} else if (strategy.by === 'name') {
|
||||
hit = this.db.get<{ id: number; name: string }>(`
|
||||
SELECT id, name FROM collection_places
|
||||
WHERE collection_id = ? AND lower(trim(name)) = ?
|
||||
ORDER BY id ASC LIMIT 1
|
||||
`, collectionId, strategy.name);
|
||||
hit = await this.collectionsRepo.findDuplicateByName(collectionId, strategy.name);
|
||||
} else {
|
||||
hit = this.db.get<{ id: number; name: string }>(`
|
||||
SELECT id, name FROM collection_places
|
||||
WHERE collection_id = ? AND lat IS NOT NULL AND lng IS NOT NULL
|
||||
AND abs(lat - ?) <= ? AND abs(lng - ?) <= ?
|
||||
ORDER BY id ASC LIMIT 1
|
||||
`, collectionId, strategy.lat, strategy.tolerance, strategy.lng, strategy.tolerance);
|
||||
hit = await this.collectionsRepo.findDuplicateByCoords(collectionId, strategy.lat, strategy.lng, strategy.tolerance);
|
||||
}
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user