refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 02:21:06 +02:00
parent 6b755314d8
commit 37992de7ab
48 changed files with 1250 additions and 1219 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ export class JournalRpc {
// gallery entry aimed at bytes that never arrived.
await this.storage.put('journey', filename, Readable.from(buf), { contentType: MIME_BY_EXT[ext] ?? 'image/jpeg' });
const photo = this.journey.addPhoto(entryId, userId, `journey/${filename}`, undefined, input.caption);
const photo = await this.journey.addPhoto(entryId, userId, `journey/${filename}`, undefined, input.caption);
if (!photo) {
// Nothing references the object now, and nothing ever would.
await this.storage.delete('journey', filename).catch(() => {});
@@ -1552,19 +1552,19 @@ export class JourneyDomainService {
* the entry route could not, which is why a video dropped on an entry came
* back as a 400 (issue #2341).
*/
addPhoto(
async addPhoto(
entryId: number,
userId: number,
filePath: string,
thumbnailPath?: string,
caption?: string,
media?: { mediaType?: string; durationMs?: number | null },
): JourneyPhoto | null {
): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = this.photos.getOrCreateLocal(
const trekPhotoId = await this.photos.getOrCreateLocal(
filePath,
thumbnailPath,
null,
@@ -1578,7 +1578,7 @@ export class JourneyDomainService {
return result;
}
addProviderPhoto(
async addProviderPhoto(
entryId: number,
userId: number,
provider: string,
@@ -1586,12 +1586,12 @@ export class JourneyDomainService {
caption?: string,
passphrase?: string,
mediaType: string = 'image',
): JourneyPhoto | null {
): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
// skip if this photo is already linked to this entry
const alreadyLinked = this.db
@@ -1629,11 +1629,11 @@ export class JourneyDomainService {
}
// Upload photos to the journey gallery only (no entry association).
uploadGalleryPhotos(
async uploadGalleryPhotos(
journeyId: number,
userId: number,
filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[],
): JourneyPhoto[] {
): Promise<JourneyPhoto[]> {
if (!this.canEdit(journeyId, userId)) return [];
const results: any[] = [];
const now = this.ts();
@@ -1643,7 +1643,7 @@ export class JourneyDomainService {
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const f of filePaths) {
const trekPhotoId = this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null);
const trekPhotoId = await this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null);
this.db.prepare(
`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
@@ -1659,7 +1659,7 @@ export class JourneyDomainService {
}
// Add a provider photo to the gallery only (no entry link).
addProviderPhotoToGallery(
async addProviderPhotoToGallery(
journeyId: number,
userId: number,
provider: string,
@@ -1667,9 +1667,9 @@ export class JourneyDomainService {
caption?: string,
passphrase?: string,
mediaType: string = 'image',
): any | null {
): Promise<any | null> {
if (!this.canEdit(journeyId, userId)) return null;
const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const galleryId = this.db.connection.transaction(() => this.ensureInGallery(journeyId, trekPhotoId, caption))();
return this.db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
}
@@ -1687,10 +1687,10 @@ export class JourneyDomainService {
}
// Hard-delete a gallery photo (removes from all entries and the gallery).
deleteGalleryPhoto(
async deleteGalleryPhoto(
journeyPhotoId: number,
userId: number,
): { photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null {
): Promise<{ photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null> {
const row = this.db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as
| { id: number; journey_id: number; photo_id: number }
| undefined;
@@ -1703,18 +1703,18 @@ export class JourneyDomainService {
// cascade on journey_entry_photos.journey_photo_id handles junction cleanup
this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
this.photos.deleteIfOrphan(row.photo_id);
await this.photos.deleteIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null };
}
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
async setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
// photoId = journey_photos.id (gallery row); look up the trek_photo_id
const jp = this.db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as
| { photo_id: number }
| undefined;
if (!jp) return;
this.photos.setProvider(jp.photo_id, provider, assetId, ownerId);
await this.photos.setProvider(jp.photo_id, provider, assetId, ownerId);
// also denorm on gallery row for fast reads
this.db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(
provider,
@@ -1752,10 +1752,10 @@ export class JourneyDomainService {
}
// deletePhoto: hard-delete (backwards compat name used by old route).
deletePhoto(
async deletePhoto(
photoId: number,
userId: number,
): { id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null {
): Promise<{ id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null> {
const row = this.db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as
| { id: number; journey_id: number; photo_id: number }
| undefined;
@@ -1767,7 +1767,7 @@ export class JourneyDomainService {
| undefined;
this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
this.photos.deleteIfOrphan(row.photo_id);
await this.photos.deleteIfOrphan(row.photo_id);
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null, journey_id: row.journey_id };
}
+19 -17
View File
@@ -251,7 +251,7 @@ export class JourneyController {
const refused: Express.Multer.File[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined);
const photo = await this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined);
if (!photo) {
// No row points at this file, so nothing would ever clean it up.
refused.push(file);
@@ -262,7 +262,7 @@ export class JourneyController {
try {
const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname);
if (immichId) {
this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id);
await this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id);
Object.assign(photo, { provider: 'immich', asset_id: immichId, owner_id: user.id });
}
} catch {
@@ -314,7 +314,7 @@ export class JourneyController {
}
await this.commitJourneyUploads(poster ? [video, poster] : [video]);
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photo = this.journey.addPhoto(
const photo = await this.journey.addPhoto(
Number(entryId),
user.id,
`journey/${video.filename}`,
@@ -332,22 +332,23 @@ export class JourneyController {
}
@Post('entries/:entryId/provider-photos')
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) {
async providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
body.asset_ids.forEach((id, i) => {
for (let i = 0; i < body.asset_ids.length; i++) {
const id = body.asset_ids[i];
const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image';
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
if (photo) added.push(photo);
});
}
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image');
const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image');
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
@@ -387,7 +388,7 @@ export class JourneyController {
@Delete('photos/:photoId')
async deletePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string) {
const photo = this.journey.deletePhoto(Number(photoId), user.id);
const photo = await this.journey.deletePhoto(Number(photoId), user.id);
if (!photo) {
throw new HttpException({ error: 'Photo not found' }, 404);
}
@@ -432,7 +433,7 @@ export class JourneyController {
}
await this.commitJourneyUploads(files);
const filePaths = files.map((f) => ({ path: `journey/${f.filename}` }));
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths);
const photos = await this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths);
if (!photos.length) {
await this.discardJourneyUploads(files);
throw new HttpException({ error: 'Not allowed' }, 403);
@@ -471,7 +472,7 @@ export class JourneyController {
}
await this.commitJourneyUploads(poster ? [video, poster] : [video]);
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, [{
const photos = await this.journey.uploadGalleryPhotos(Number(id), user.id, [{
path: `journey/${video.filename}`,
thumbnail: poster ? `journey/${poster.filename}` : undefined,
mediaType: 'video',
@@ -489,22 +490,23 @@ export class JourneyController {
}
@Post(':id/gallery/provider-photos')
galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) {
async galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
body.asset_ids.forEach((aid, i) => {
for (let i = 0; i < body.asset_ids.length; i++) {
const aid = body.asset_ids[i];
const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image';
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
if (photo) added.push(photo);
});
}
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image');
const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image');
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
@@ -515,7 +517,7 @@ export class JourneyController {
@Delete(':id/gallery/:journeyPhotoId')
@HttpCode(204)
async deleteGalleryPhoto(@CurrentUser() user: User, @Param('journeyPhotoId') journeyPhotoId: string): Promise<void> {
const photo = this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
const photo = await this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
if (!photo) {
throw new HttpException({ error: 'Photo not found or not allowed' }, 404);
}
+5 -4
View File
@@ -562,13 +562,14 @@ export class JourneyMcp {
}
const photos: unknown[] = [];
asset_ids.forEach((assetId, i) => {
for (let i = 0; i < asset_ids.length; i++) {
const assetId = asset_ids[i];
const mediaType = media_types?.[i] === 'video' ? 'video' : 'image';
const photo = entryId === undefined
? this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType)
: this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType);
? await this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType)
: await this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType);
if (photo) photos.push(photo);
});
}
// Detached, exactly as the REST routes schedule it: the provider is asked
// when and where each photo was taken, and without that answer an attached
@@ -42,7 +42,7 @@ export class ImmichMemoriesController {
return;
}
if (typeof auto_upload === 'boolean') {
this.memories.immichSetAutoUpload(user.id, auto_upload);
await this.memories.immichSetAutoUpload(user.id, auto_upload);
}
if (result.warning) {
res.json({ success: true, warning: result.warning });
@@ -102,7 +102,7 @@ export class ImmichMemoriesController {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
@@ -126,7 +126,7 @@ export class ImmichMemoriesController {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
@@ -145,7 +145,7 @@ export class ImmichMemoriesController {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
+15 -15
View File
@@ -40,7 +40,7 @@ export class ImmichService {
private readonly storage: StorageService,
) {}
getImmichCredentials(userId: number) {
async getImmichCredentials(userId: number) {
const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
if (!user?.immich_url || !user?.immich_api_key) return null;
const apiKey = decrypt_api_key(user.immich_api_key);
@@ -71,8 +71,8 @@ export class ImmichService {
// ── Connection Settings ────────────────────────────────────────────────────
getConnectionSettings(userId: number) {
const creds = this.getImmichCredentials(userId);
async getConnectionSettings(userId: number) {
const creds = await this.getImmichCredentials(userId);
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
return {
immich_url: creds?.immich_url || '',
@@ -81,7 +81,7 @@ export class ImmichService {
};
}
setImmichAutoUpload(userId: number, enabled: boolean): void {
async setImmichAutoUpload(userId: number, enabled: boolean): Promise<void> {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
@@ -169,7 +169,7 @@ export class ImmichService {
async getConnectionStatus(
userId: number
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
try {
const resp = await safeFetch(`${creds.immich_url}/api/users/me`, {
@@ -189,7 +189,7 @@ export class ImmichService {
async browseTimeline(
userId: number
): Promise<{ buckets?: any; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -271,7 +271,7 @@ export class ImmichService {
page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
// Once a day filter is in play the raw pages and the answered pages stop
@@ -347,7 +347,7 @@ export class ImmichService {
ownerUserId?: number
): Promise<{ data?: any; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
const creds = await this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
@@ -390,7 +390,7 @@ export class ImmichService {
ownerUserId?: number
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
const creds = await this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
@@ -430,7 +430,7 @@ export class ImmichService {
opts?: { mediaType?: string | null; range?: string },
): Promise<void> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
const creds = await this.getImmichCredentials(effectiveUserId);
if (!creds) {
handleServiceResult(response, fail('Not found', 404));
return;
@@ -467,7 +467,7 @@ export class ImmichService {
async listAlbums(
userId: number
): Promise<{ albums?: any[]; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -591,7 +591,7 @@ export class ImmichService {
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -639,10 +639,10 @@ export class ImmichService {
linkId: string,
userId: number,
): Promise<{ selection: Selection; total: number } | { error: string; status: number }> {
const response = this.access.getAlbumIdFromLink(tripId, linkId, userId);
const response = await this.access.getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -664,7 +664,7 @@ export class ImmichService {
// ── Upload to Immich ──────────────────────────────────────────────────────
async uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return null;
// Journey uploads store the uploads-relative 'journey/<file>' path; only
@@ -15,7 +15,7 @@ import { fail, success, type ServiceResult } from './memories.helpers';
export class MemoriesAccessService {
constructor(private readonly db: DatabaseService) {}
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
async canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise<boolean> {
if (requestingUserId === ownerUserId) {
return true;
}
@@ -63,7 +63,7 @@ export class MemoriesAccessService {
// ── Unified photo access check (trek_photos based) ──────────────────────
canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
async canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): Promise<boolean> {
const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId);
if (!photo) return false;
@@ -107,7 +107,7 @@ export class MemoriesAccessService {
// ── Album link syncing ──────────────────────────────────────────────────
getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
async getAlbumIdFromLink(tripId: string, linkId: string, userId: number): Promise<ServiceResult<string>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
@@ -123,7 +123,7 @@ export class MemoriesAccessService {
}
}
getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
async getAlbumLinkForSync(tripId: string, linkId: string, userId: number): Promise<ServiceResult<{ albumId: string; passphrase?: string }>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
@@ -142,7 +142,7 @@ export class MemoriesAccessService {
}
}
updateSyncTimeForAlbumLink(linkId: string): void {
async updateSyncTimeForAlbumLink(linkId: string): Promise<void> {
this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId);
}
}
+3 -3
View File
@@ -25,7 +25,7 @@ export class MemoriesService {
) {}
// ── Access check (reused by both provider asset routes) ──────────────────
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise<boolean> {
return this.access.canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
}
@@ -71,8 +71,8 @@ export class MemoriesService {
return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
this.immich.setImmichAutoUpload(userId, enabled);
immichSetAutoUpload(userId: number, enabled: boolean): Promise<void> {
return this.immich.setImmichAutoUpload(userId, enabled);
}
immichGetConnectionStatus(userId: number) {
@@ -35,7 +35,7 @@ export class PhotoCaptureBackfillService {
async run(trekPhotoIds: number[], userId: number): Promise<void> {
for (const id of trekPhotoIds) {
try {
const photo = this.photos.resolve(id);
const photo = await this.photos.resolve(id);
// A row that already knows both has nothing to gain, and a provider call
// per photo is the expensive part of an album import.
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
@@ -44,14 +44,14 @@ export class PhotoCaptureBackfillService {
// getPhotoInfo would only hand back what the DB row already says.
if (photo.provider === 'local') {
const meta = await this.readLocalExif(photo.file_path);
if (meta) this.photos.recordCaptureMetadata(id, meta);
if (meta) await this.photos.recordCaptureMetadata(id, meta);
continue;
}
const info = await this.resolver.getPhotoInfo(userId, id);
if (!info.success) continue;
this.photos.recordCaptureMetadata(id, {
await this.photos.recordCaptureMetadata(id, {
takenAt: info.data.takenAt ?? null,
lat: info.data.lat ?? null,
lng: info.data.lng ?? null,
@@ -76,7 +76,7 @@ export class PhotoResolverService {
kind: 'thumbnail' | 'original',
range?: string,
): Promise<void> {
const photo = this.photos.resolve(photoId);
const photo = await this.photos.resolve(photoId);
if (!photo) {
res.status(404).json({ error: 'Photo not found' });
return;
@@ -92,7 +92,7 @@ export class PhotoResolverService {
const result = await this.thumbnails.ensureLocalThumbnail(photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height);
await this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height);
}
}
if (thumbRel) {
@@ -169,7 +169,7 @@ export class PhotoResolverService {
userId: number,
photoId: number,
): Promise<ServiceResult<AssetInfo>> {
const photo = this.photos.resolve(photoId);
const photo = await this.photos.resolve(photoId);
if (!photo) return fail('Photo not found', 404);
// Local rows answer from the row itself — nothing to ask.
@@ -148,7 +148,7 @@ export class SynologyMemoriesController {
@Res() res: Response,
): Promise<void> {
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos'))) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase));
@@ -176,7 +176,7 @@ export class SynologyMemoriesController {
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos'))) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase);
+17 -17
View File
@@ -134,7 +134,7 @@ export class SynologyService {
private readonly notifications: NotificationsService,
) {}
private _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
private async _readSynologyUser(userId: number, columns: string[]): Promise<ServiceResult<SynologyUserRecord>> {
try {
const row = this.db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
@@ -153,8 +153,8 @@ export class SynologyService {
}
}
private _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
private async _getSynologyCredentials(userId: number): Promise<ServiceResult<SynologyCredentials>> {
const user = await this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password);
@@ -251,7 +251,7 @@ export class SynologyService {
}
private async _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
const creds = this._getSynologyCredentials(userId);
const creds = await this._getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<T>;
}
@@ -266,7 +266,7 @@ export class SynologyService {
const result = await this._fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
this._clearSynologySID(userId);
await this._clearSynologySID(userId);
const retrySession = await this._getSynologySession(userId);
if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>;
@@ -305,11 +305,11 @@ export class SynologyService {
}
private _clearSynologySID(userId: number): void {
private async _clearSynologySID(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
private _clearSynologySession(userId: number): void {
private async _clearSynologySession(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
@@ -322,15 +322,15 @@ export class SynologyService {
}
private async _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cached = this._readSynologyUser(userId, ['synology_sid', 'synology_did']);
const cached = await this._readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
this._clearSynologySID(userId);
await this._clearSynologySID(userId);
}
const creds = this._getSynologyCredentials(userId);
const creds = await this._getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<string>;
}
@@ -354,7 +354,7 @@ export class SynologyService {
}
async getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
const creds = this._getSynologyCredentials(userId);
const creds = await this._getSynologyCredentials(userId);
if (!creds.success) return creds as ServiceResult<SynologySettings>;
const session = await this._getSynologySession(userId);
return success({
@@ -372,7 +372,7 @@ export class SynologyService {
return fail(ssrf.error, 400);
}
const result = this._readSynologyUser(userId, ['synology_password'])
const result = await this._readSynologyUser(userId, ['synology_password'])
if (!result.success) return result as ServiceResult<string>;
const existingEncryptedPassword = result.data?.synology_password || null;
@@ -383,12 +383,12 @@ export class SynologyService {
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = this._readSynologyUser(userId, ['synology_url', 'synology_username']);
const existing = await this._readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
this._clearSynologySession(userId);
await this._clearSynologySession(userId);
// Fire-and-forget like unifiedService's photos_shared send — without the
// .catch an in-app insert failure became an unhandled rejection.
// Injected, not imported from the old notifications bridge — that module built
@@ -537,7 +537,7 @@ export class SynologyService {
* immichService for why the trip-side write lives in the unified service now.
*/
async collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise<ServiceResult<{ selection: Selection; total: number }>> {
const response = this.access.getAlbumLinkForSync(tripId, linkId, userId);
const response = await this.access.getAlbumLinkForSync(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>;
const { albumId, passphrase } = response.data;
@@ -654,7 +654,7 @@ export class SynologyService {
const parsedId = this._splitPackedSynologyId(photoId);
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
const synology_credentials = this._getSynologyCredentials(targetUserId);
const synology_credentials = await this._getSynologyCredentials(targetUserId);
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
const sid = await this._getSynologySession(targetUserId);
@@ -704,7 +704,7 @@ export class SynologyService {
return;
}
const synology_credentials = this._getSynologyCredentials(targetUserId);
const synology_credentials = await this._getSynologyCredentials(targetUserId);
if (!synology_credentials.success) {
handleServiceResult(response, synology_credentials);
return;
@@ -17,6 +17,7 @@ import {
type SyncAlbumResult,
} from './memories.helpers';
import { NotificationsService } from '../notifications/notifications.service';
import { UnitOfWork } from '../database/unit-of-work';
/**
* The provider-agnostic trip photo surface: which photos a trip shows, which
@@ -34,6 +35,7 @@ export class UnifiedMemoriesService {
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
) {}
private async _providers(): Promise<Array<{id: string; enabled: boolean}>> {
@@ -139,7 +141,7 @@ export class UnifiedMemoriesService {
return providerResult as ServiceResult<boolean>;
}
try {
const photoId = this.photos.getOrCreate(provider, assetId, userId, passphrase);
const photoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase);
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
@@ -221,12 +223,12 @@ export class UnifiedMemoriesService {
}
}
removeTripPhoto(
async removeTripPhoto(
tripId: string,
userId: number,
photoId: number,
sid?: string,
): ServiceResult<true> {
): Promise<ServiceResult<true>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -240,7 +242,7 @@ export class UnifiedMemoriesService {
AND photo_id = ?
`).run(tripId, userId, photoId);
this.photos.deleteIfOrphan(photoId);
await this.photos.deleteIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
@@ -291,7 +293,7 @@ export class UnifiedMemoriesService {
}
}
removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
async removeAlbumLink(tripId: string, linkId: string, userId: number): Promise<ServiceResult<true>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -301,7 +303,7 @@ export class UnifiedMemoriesService {
const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
@@ -309,7 +311,7 @@ export class UnifiedMemoriesService {
});
for (const { photo_id } of linkedPhotos) {
this.photos.deleteIfOrphan(photo_id);
await this.photos.deleteIfOrphan(photo_id);
}
return success(true);
@@ -362,7 +364,7 @@ export class UnifiedMemoriesService {
const result = await this.addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId);
if ('error' in result) return { error: result.error.message, status: result.error.status };
this.access.updateSyncTimeForAlbumLink(linkId);
await this.access.updateSyncTimeForAlbumLink(linkId);
return { success: true, added: result.data.added, total: collected.total };
}
@@ -379,7 +381,7 @@ export class UnifiedMemoriesService {
const result = await this.addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
this.access.updateSyncTimeForAlbumLink(linkId);
await this.access.updateSyncTimeForAlbumLink(linkId);
return success({ added: result.data.added, total: collected.data.total });
}
@@ -72,7 +72,7 @@ export class UnifiedMemoriesController {
@Body() body: RemoveTripPhotoDto,
@Res() res: Response,
): Promise<void> {
const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
const result = await this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
@@ -108,13 +108,13 @@ export class UnifiedMemoriesController {
}
@Delete('trips/:tripId/album-links/:linkId')
removeAlbumLink(
async removeAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Res() res: Response,
): void {
const result = this.memories.removeAlbumLink(tripId, linkId, user.id);
): Promise<void> {
const result = await this.memories.removeAlbumLink(tripId, linkId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
@@ -39,19 +39,19 @@ export class AdminPackingTemplatesController {
) {}
@Get()
list() {
return { templates: this.packing.listPackingTemplates() };
async list() {
return { templates: await this.packing.listPackingTemplates() };
}
@Get(':id')
get(@Param('id') id: string) {
return ok(this.packing.getPackingTemplate(id));
async get(@Param('id') id: string) {
return ok(await this.packing.getPackingTemplate(id));
}
@Post()
@HttpCode(201)
async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
const result = ok(this.packing.createPackingTemplate(body.name, user.id));
const result = ok(await this.packing.createPackingTemplate(body.name, user.id));
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_create',
@@ -63,13 +63,13 @@ export class AdminPackingTemplatesController {
}
@Put(':id')
update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.updatePackingTemplate(id, body));
async update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updatePackingTemplate(id, body));
}
@Delete(':id')
async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.packing.deletePackingTemplate(id));
const result = ok(await this.packing.deletePackingTemplate(id));
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_delete',
@@ -82,35 +82,35 @@ export class AdminPackingTemplatesController {
@Post(':id/categories')
@HttpCode(201)
createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.createTemplateCategory(id, body.name));
async createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.createTemplateCategory(id, body.name));
}
@Put(':templateId/categories/:catId')
updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.updateTemplateCategory(templateId, catId, body));
async updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updateTemplateCategory(templateId, catId, body));
}
@Delete(':templateId/categories/:catId')
deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
ok(this.packing.deleteTemplateCategory(templateId, catId));
async deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
ok(await this.packing.deleteTemplateCategory(templateId, catId));
return { success: true };
}
@Post(':templateId/categories/:catId/items')
@HttpCode(201)
createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.createTemplateItem(templateId, catId, body.name));
async createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.createTemplateItem(templateId, catId, body.name));
}
@Put(':templateId/items/:itemId')
updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.updateTemplateItem(templateId, itemId, body));
async updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updateTemplateItem(templateId, itemId, body));
}
@Delete(':templateId/items/:itemId')
deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) {
ok(this.packing.deleteTemplateItem(templateId, itemId));
async deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) {
ok(await this.packing.deleteTemplateItem(templateId, itemId));
return { success: true };
}
}
+42 -42
View File
@@ -64,14 +64,14 @@ export class PackingController {
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
// Pass the viewer so private items (#858) owned by other members are hidden.
return { items: this.packing.listItems(tripId, user.id) };
return { items: await this.packing.listItems(tripId, user.id) };
}
@RequirePermission('packing_edit')
@Post('import')
importItems(
async importItems(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingImportDto,
@@ -81,7 +81,7 @@ export class PackingController {
if (body.items.length === 0) {
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
}
const created = this.packing.bulkImport(tripId, body.items, user.id);
const created = await this.packing.bulkImport(tripId, body.items, user.id);
for (const item of created) {
this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId);
}
@@ -91,14 +91,14 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post()
create(
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingCreateItemDto,
@Headers('x-socket-id') socketId?: string,
) {
// checked arrives as boolean or legacy 0/1 — the service coerces by truthiness.
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
const item = await this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
// A bag referenced in the body must exist on this trip (#2154). The payload
// is at fault, so 400 — the 404 'Bag not found' stays with the path routes.
if (isInvalidBagRef(item)) {
@@ -111,19 +111,19 @@ export class PackingController {
@RequirePermission('packing_edit')
@Put('reorder')
reorder(
async reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingReorderDto,
@Headers('x-socket-id') _socketId?: string,
) {
this.packing.reorderItems(tripId, body.orderedIds);
await this.packing.reorderItems(tripId, body.orderedIds);
return { success: true };
}
@RequirePermission('packing_edit')
@Put(':id')
update(
async update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -133,12 +133,12 @@ export class PackingController {
) {
// Privacy state before the change, so a public↔private toggle (#858) can route
// the broadcast correctly instead of leaking a freshly-privatized item.
const before = this.packing.getItemPrivacy(tripId, id);
const before = await this.packing.getItemPrivacy(tripId, id);
const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body;
// bodyKeys carries which keys the request actually provided (the presence-
// sentinel protocol); the parsed body only ever holds known schema keys.
// checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds.
const updated = this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id);
const updated = await this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -162,13 +162,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Delete(':id')
remove(
async remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const deleted = this.packing.deleteItem(tripId, id, user.id);
const deleted = await this.packing.deleteItem(tripId, id, user.id);
if (!deleted) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -180,14 +180,14 @@ export class PackingController {
@RequirePermission('packing_edit')
@Put(':id/sharing')
setSharing(
async setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: PackingSetSharingDto,
@Headers('x-socket-id') socketId?: string,
) {
const updated = this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []);
const updated = await this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -204,13 +204,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post(':id/clone')
@HttpCode(201)
clone(
async clone(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const item = this.packing.cloneItem(tripId, id, user.id);
const item = await this.packing.cloneItem(tripId, id, user.id);
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -222,13 +222,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post(':id/contributors')
addContributor(
async addContributor(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const item = this.packing.addContributor(tripId, id, user.id);
const item = await this.packing.addContributor(tripId, id, user.id);
if (!item) {
throw new HttpException({ error: 'Item not found or not a shared list item' }, 404);
}
@@ -239,7 +239,7 @@ export class PackingController {
@RequirePermission('packing_edit')
@Delete(':id/contributors/:userId')
removeContributor(
async removeContributor(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -248,7 +248,7 @@ export class PackingController {
) {
// You can drop your own pledge; the owner can remove anyone's.
const target = Number.parseInt(userId);
const item = this.packing.removeContributor(tripId, id, target);
const item = await this.packing.removeContributor(tripId, id, target);
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -257,16 +257,16 @@ export class PackingController {
}
@Get('bags')
listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
async listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
// unassigned_weight_grams rides along so the "no bag" pile and the grand
// total follow the same rule as the bags themselves (#2191) — a screen
// mixing true totals with per-viewer ones would be worse than either.
return this.packing.listBagsWithWeights(tripId);
return await this.packing.listBagsWithWeights(tripId);
}
@RequirePermission('packing_edit')
@Post('bags')
createBag(
async createBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingCreateBagDto,
@@ -276,14 +276,14 @@ export class PackingController {
if (!body.name.trim()) {
throw new HttpException({ error: 'Name is required' }, 400);
}
const bag = this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams });
const bag = await this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams });
this.packing.broadcast(tripId, 'packing:bag-created', { bag }, socketId);
return { bag };
}
@RequirePermission('packing_edit')
@Put('bags/:bagId')
updateBag(
async updateBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@@ -293,7 +293,7 @@ export class PackingController {
const { name, color, weight_limit_grams, user_id } = body;
// bodyKeys carries which keys the request actually provided (the presence-
// sentinel protocol); the parsed body only ever holds known schema keys.
const updated = this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body));
const updated = await this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body));
if (!updated) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
@@ -303,13 +303,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Delete('bags/:bagId')
deleteBag(
async deleteBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.packing.deleteBag(tripId, bagId)) {
if (!(await this.packing.deleteBag(tripId, bagId))) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
this.packing.broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, socketId);
@@ -320,14 +320,14 @@ export class PackingController {
}
@Get('templates')
listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { templates: this.packing.listTemplates() };
async listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { templates: await this.packing.listTemplates() };
}
@RequirePermission('packing_edit')
@Post('apply-template/:templateId')
@HttpCode(200)
applyTemplate(
async applyTemplate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('templateId') templateId: string,
@@ -335,7 +335,7 @@ export class PackingController {
@Headers('x-socket-id') socketId?: string,
) {
const visibility = body?.visibility === 'personal' ? 'personal' : 'common';
const added = this.packing.applyTemplate(tripId, templateId, visibility, user.id);
const added = await this.packing.applyTemplate(tripId, templateId, visibility, user.id);
if (!added) {
throw new HttpException({ error: 'Template not found or empty' }, 404);
}
@@ -346,14 +346,14 @@ export class PackingController {
@RequirePermission('packing_edit')
@Put('bags/:bagId/members')
setBagMembers(
async setBagMembers(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@Body() body: PackingBagMembersDto,
@Headers('x-socket-id') socketId?: string,
) {
const members = this.packing.setBagMembers(tripId, bagId, body.user_ids);
const members = await this.packing.setBagMembers(tripId, bagId, body.user_ids);
if (!members) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
@@ -363,7 +363,7 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post('save-as-template')
saveAsTemplate(
async saveAsTemplate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingSaveTemplateDto,
@@ -375,7 +375,7 @@ export class PackingController {
if (!body.name.trim()) {
throw new HttpException({ error: 'Template name is required' }, 400);
}
const template = this.packing.saveAsTemplate(tripId, user.id, body.name.trim());
const template = await this.packing.saveAsTemplate(tripId, user.id, body.name.trim());
if (!template) {
throw new HttpException({ error: 'No items to save' }, 400);
}
@@ -383,13 +383,13 @@ export class PackingController {
}
@Get('category-assignees')
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { assignees: this.packing.getCategoryAssignees(tripId) };
async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { assignees: await this.packing.getCategoryAssignees(tripId) };
}
@RequirePermission('packing_edit')
@Put('category-assignees/:categoryName')
updateCategoryAssignees(
async updateCategoryAssignees(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('categoryName') categoryName: string,
@@ -397,9 +397,9 @@ export class PackingController {
@Headers('x-socket-id') socketId?: string,
) {
const category = decodeURIComponent(categoryName);
const rows = this.packing.updateCategoryAssignees(tripId, category, body.user_ids);
const rows = await this.packing.updateCategoryAssignees(tripId, category, body.user_ids);
this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId);
this.packing.notifyTagged(tripId, user, category, body.user_ids);
await this.packing.notifyTagged(tripId, user, category, body.user_ids);
return { assignees: rows };
}
}
+41 -41
View File
@@ -80,9 +80,9 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.createItem(tripId, {
const item = await this.packing.createItem(tripId, {
name,
category: category || 'General',
bag_id,
@@ -117,9 +117,9 @@ export class PackingMcp {
})
async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
const item = await this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
if (!item) return errorResult('Packing item not found.');
// Scoped to the people who may see it, exactly as the REST route does
// (#858, #1976). A Common item answers null here and goes to the room.
@@ -140,9 +140,9 @@ export class PackingMcp {
})
async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId);
const deleted = await this.packing.deleteItem(tripId, itemId, ctx.userId);
if (!deleted) return errorResult('Packing item not found.');
// deleteItem hands back the row it removed, so the delete can be scoped to
// the same people the item was ever visible to (#1976).
@@ -175,7 +175,7 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
// The service reads presence from bodyKeys, so a field has to be named there
@@ -184,8 +184,8 @@ export class PackingMcp {
// Privacy state before the write, so a public↔private flip routes the
// broadcast the way the REST route does instead of leaking a freshly
// privatized item (or leaving a stale copy on everyone else's screen).
const wasPrivate = !!this.packing.getItemPrivacy(tripId, itemId)?.is_private;
const item = this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId);
const wasPrivate = !!(await this.packing.getItemPrivacy(tripId, itemId))?.is_private;
const item = await this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId);
if (!item) return errorResult('Packing item not found.');
// A referenced bag must exist on this trip (#2154), as on the REST route.
if (isInvalidBagRef(item)) return errorResult('Bag not found.');
@@ -231,9 +231,9 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
const item = await this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
if (!item) return errorResult('Packing item not found.');
if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.');
// The viewer set just changed: drop the item from the whole room, then hand
@@ -259,9 +259,9 @@ export class PackingMcp {
})
async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.reorderItems(tripId, orderedIds);
await this.packing.reorderItems(tripId, orderedIds);
this.guards.safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
}
@@ -277,8 +277,8 @@ export class PackingMcp {
access: { group: 'packing', mode: 'read' },
})
async listPackingBags({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const bags = this.packing.listBags(tripId);
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const bags = await this.packing.listBags(tripId);
return ok({ bags });
}
@@ -297,11 +297,11 @@ export class PackingMcp {
})
async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
// listBags and the schema always carry, so the client/AI consumer matches.
const bag = { ...(this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] };
const bag = { ...(await this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] };
this.guards.safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
}
@@ -326,7 +326,7 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {};
const bodyKeys: string[] = [];
@@ -336,10 +336,10 @@ export class PackingMcp {
// an explicit null clears it.
if (weight_limit_grams !== undefined) { fields.weight_limit_grams = weight_limit_grams; bodyKeys.push('weight_limit_grams'); }
if (user_id !== undefined) { fields.user_id = user_id; bodyKeys.push('user_id'); }
const updated = this.packing.updateBag(tripId, bagId, fields, bodyKeys);
const updated = await this.packing.updateBag(tripId, bagId, fields, bodyKeys);
if (!updated) return errorResult('Bag not found.');
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
const bag = this.packing.listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
const bag = (await this.packing.listBags(tripId)).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
this.guards.safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
}
@@ -357,9 +357,9 @@ export class PackingMcp {
})
async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.deleteBag(tripId, bagId);
await this.packing.deleteBag(tripId, bagId);
// { bagId } matches the REST route and the plugin host (the legacy
// registrar's { id } was the odd one out).
this.guards.safeBroadcast(tripId, 'packing:bag-deleted', { bagId });
@@ -383,9 +383,9 @@ export class PackingMcp {
})
async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const members = this.packing.setBagMembers(tripId, bagId, userIds);
const members = await this.packing.setBagMembers(tripId, bagId, userIds);
if (!members) return errorResult('Bag not found.');
this.guards.safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
return ok({ members });
@@ -402,8 +402,8 @@ export class PackingMcp {
access: { group: 'packing', mode: 'read' },
})
async getPackingCategoryAssignees({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const assignees = this.packing.getCategoryAssignees(tripId);
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const assignees = await this.packing.getCategoryAssignees(tripId);
return ok({ assignees });
}
@@ -421,9 +421,9 @@ export class PackingMcp {
})
async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
const assignees = await this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
this.guards.safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
return ok({ assignees });
}
@@ -441,9 +441,9 @@ export class PackingMcp {
})
async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const items = this.packing.applyTemplate(tripId, templateId);
const items = await this.packing.applyTemplate(tripId, templateId);
if (items === null) return errorResult('Template not found.');
this.guards.safeBroadcast(tripId, 'packing:template-applied', { items });
this.packing.broadcastBagTotals(String(tripId));
@@ -461,8 +461,8 @@ export class PackingMcp {
access: { group: 'packing', mode: 'read' },
})
async listPackingTemplates({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
return ok({ templates: this.packing.listTemplates() });
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
return ok({ templates: await this.packing.listTemplates() });
}
@Tool({
@@ -478,11 +478,11 @@ export class PackingMcp {
})
async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
const template = await this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
if (!template) return errorResult('Nothing to save — the packing list is empty.');
return ok({ template });
}
@@ -501,7 +501,7 @@ export class PackingMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Templates are global; the REST route restricts management to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const result = this.packing.deletePackingTemplate(String(templateId));
const result = await this.packing.deletePackingTemplate(String(templateId));
if ('error' in result) return errorResult(result.error);
return ok({ success: true, name: result.name });
}
@@ -529,9 +529,9 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const created = this.packing.bulkImport(tripId, items, ctx.userId);
const created = await this.packing.bulkImport(tripId, items, ctx.userId);
for (const item of created) {
this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item));
}
@@ -551,7 +551,7 @@ export class PackingMcp {
})
async tripPackingResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.packing.verifyTripAccess(id, ctx.userId)) {
if (id === null || !(await this.packing.verifyTripAccess(id, ctx.userId))) {
return {
contents: [{
uri: uri.href,
@@ -561,7 +561,7 @@ export class PackingMcp {
};
}
// Hide other members' private items (#858) from the requesting user.
const items = this.packing.listItems(id, ctx.userId);
const items = await this.packing.listItems(id, ctx.userId);
return {
contents: [{
uri: uri.href,
@@ -581,7 +581,7 @@ export class PackingMcp {
})
async tripPackingBagsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.packing.verifyTripAccess(id, ctx.userId)) {
if (id === null || !(await this.packing.verifyTripAccess(id, ctx.userId))) {
return {
contents: [{
uri: uri.href,
@@ -590,7 +590,7 @@ export class PackingMcp {
}],
};
}
const bags = this.packing.listBags(id);
const bags = await this.packing.listBags(id);
return {
contents: [{
uri: uri.href,
+9 -9
View File
@@ -47,7 +47,7 @@ export class PackingRpc {
const parsed = packingCreateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
const item = await this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
// A referenced bag must exist on this trip (#2154), as on the REST route.
if (isInvalidBagRef(item)) throw new BadParams(`no packing bag ${parsed.data.bag_id} on trip ${tripId}`);
this.packing.emitToViewers(String(tripId), 'packing:created', { item }, item, undefined);
@@ -64,9 +64,9 @@ export class PackingRpc {
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
// Read the privacy BEFORE the write, so a public/private toggle routes correctly.
const before = this.packing.getItemPrivacy(tripId, itemId);
const before = await this.packing.getItemPrivacy(tripId, itemId);
const input = parsed.data as Record<string, unknown>;
const updated = this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actor);
const updated = await this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actor);
if (!updated) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
if (isUpdateConflict(updated)) throw new BadParams('packing item was modified concurrently');
// A referenced bag must exist on this trip (#2154), as on the REST route.
@@ -84,7 +84,7 @@ export class PackingRpc {
const itemId = num(params.itemId, 'itemId');
const actor = this.guards.requireActor(ctx, 'packing item');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const deleted = this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null;
const deleted = await this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null;
if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
this.packing.emitToViewers(String(tripId), 'packing:deleted', { itemId }, deleted, undefined);
this.packing.broadcastBagTotals(String(tripId));
@@ -95,7 +95,7 @@ export class PackingRpc {
async listBags(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown[]> {
// Note the permission: the envelope really does gate this READ on the write
// grant. The decorator makes the oddity visible instead of burying it.
return await this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]);
return await this.guards.tripRead(params, ctx, async () => (await this.packing.listBags(String(num(params.tripId, 'tripId')))) as unknown[]);
}
@PluginMethod('packing.createBag', { permission: 'db:write:packing' })
@@ -105,7 +105,7 @@ export class PackingRpc {
const input = asPayload(params.input);
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const bag = this.packing.createBag(String(tripId), {
const bag = await this.packing.createBag(String(tripId), {
name: input.name,
color: typeof input.color === 'string' ? input.color : undefined,
weight_limit_grams: typeof input.weight_limit_grams === 'number' ? input.weight_limit_grams : undefined,
@@ -121,7 +121,7 @@ export class PackingRpc {
const actor = this.guards.requireActor(ctx, 'packing bag');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const input = asPayload(params.input);
const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input));
const bag = await this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input));
if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'packing:bag-updated', { bag }, undefined);
return bag;
@@ -133,7 +133,7 @@ export class PackingRpc {
const bagId = num(params.bagId, 'bagId');
const actor = this.guards.requireActor(ctx, 'packing bag');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
if (!this.packing.deleteBag(String(tripId), String(bagId))) {
if (!(await this.packing.deleteBag(String(tripId), String(bagId)))) {
throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
}
this.realtime.broadcast(tripId, 'packing:bag-deleted', { bagId }, undefined);
@@ -151,7 +151,7 @@ export class PackingRpc {
// userIds sits on the params object itself, not under `input`.
const raw = asPayload(params).userIds;
const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : [];
const members = this.packing.setBagMembers(String(tripId), String(bagId), userIds);
const members = await this.packing.setBagMembers(String(tripId), String(bagId), userIds);
if (!members) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'packing:bag-members-updated', { bagId, members }, undefined);
return members;
+82 -80
View File
@@ -7,6 +7,7 @@ import type { UpdateConflict } from '../common/conflictResult';
import type { User } from '../../types';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
import { UnitOfWork } from '../database/unit-of-work';
/** Privacy fields stamped on a packing item (#858). */
type PrivacyFields = { is_private?: number; owner_id?: number | null };
@@ -60,9 +61,10 @@ export class PackingService {
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
async verifyTripAccess(tripId: string | number, userId: number) {
return this.db.canAccessTrip(tripId, userId);
}
@@ -168,7 +170,7 @@ export class PackingService {
* items (#858 three-tier sharing). Batched so the list endpoint stays one round
* of queries regardless of item count.
*/
private enrichItems(items: any[]): any[] {
private async enrichItems(items: any[]): Promise<any[]> {
if (items.length === 0) return items;
const ids = items.map(i => i.id);
const placeholders = ids.map(() => '?').join(',');
@@ -206,7 +208,7 @@ export class PackingService {
}));
}
listItems(tripId: string | number, userId?: number) {
async listItems(tripId: string | number, userId?: number) {
// Three-tier visibility (#858): Common (is_private=0) is visible to everyone;
// Personal/Shared (is_private=1) only to the owner (bringer) and the recipients
// it was explicitly shared with. Without a userId the unfiltered list is
@@ -229,12 +231,12 @@ export class PackingService {
ORDER BY sort_order ASC, created_at ASC
`, tripId, userId, userId);
}
return this.enrichItems(rows);
return await this.enrichItems(rows);
}
/** Reads an item's current privacy fields (#858) before an update, so the
* controller can detect a public↔private transition and route the broadcast. */
getItemPrivacy(tripId: string | number, id: string | number): PrivacyFields | undefined {
async getItemPrivacy(tripId: string | number, id: string | number): Promise<PrivacyFields | undefined> {
return this.db.get<PrivacyFields>('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?', id, tripId);
}
@@ -244,18 +246,18 @@ export class PackingService {
return isPrivateFallback ? 1 : 0;
}
createItem(
async createItem(
tripId: string | number,
data: { name: string; category?: string; checked?: boolean; quantity?: number; weight_grams?: number | null; bag_id?: number | null; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] },
ownerId?: number,
) {
if (data.bag_id != null && !this.bagInTrip(tripId, data.bag_id)) return { invalidBag: true } as const;
if (data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) return { invalidBag: true } as const;
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?', tripId)!;
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1));
const isPrivate = this.visibilityToPrivate(data.visibility, data.is_private);
const itemId = this.db.transaction(() => {
const itemId = await this.uow.transactional(async () => {
const result = this.db.run(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, weight_grams, bag_id, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
tripId, data.name, data.checked ? 1 : 0, data.category || 'Other', sortOrder, qty, data.weight_grams ?? null, data.bag_id ?? null, isPrivate, ownerId ?? null
@@ -264,26 +266,26 @@ export class PackingService {
// "Shared with specific people" — record the recipients it covers.
if (data.visibility === 'shared' && Array.isArray(data.recipient_ids)) {
const ins = this.db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of data.recipient_ids) if (uid !== ownerId && roster.has(uid)) ins.run(id, uid);
}
return id;
});
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', itemId)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', itemId)]))[0];
}
updateItem(
async updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean },
bodyKeys: string[],
ifMatch?: string,
actingUserId?: number,
): unknown | UpdateConflict | null {
): Promise<unknown | UpdateConflict | null> {
// Was a trip-scoped lookup, which let any member with packing_edit write to
// another member's restricted item (and read it back off the response).
const item = this.getItemInTrip(tripId, id, actingUserId);
const item = await this.getItemInTrip(tripId, id, actingUserId);
if (!item) return null;
// Optimistic concurrency (#1135): reject a stale offline overwrite. Absent
@@ -294,7 +296,7 @@ export class PackingService {
// A non-null bag about to be bound must belong to this trip (#2154) — the
// FK alone let any member point an item at another trip's bag.
if (bodyKeys.includes('bag_id') && data.bag_id != null && !this.bagInTrip(tripId, data.bag_id)) {
if (bodyKeys.includes('bag_id') && data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) {
return { invalidBag: true } as const;
}
@@ -332,7 +334,7 @@ export class PackingService {
id
);
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
// ── Three-tier sharing (#858): recipients, contributors, clone ───────────────
@@ -359,7 +361,7 @@ export class PackingService {
* these routes cannot be used to probe for ids. A missing actor denies too,
* rather than falling through unfiltered.
*/
private getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) {
private async getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) {
if (actorId == null) return undefined;
return this.db.get<{ id: number; owner_id: number | null; is_private: number; name: string; category: string | null; quantity: number; weight_grams: number | null; bag_id: number | null; updated_at?: string | null }>(
`SELECT * FROM packing_items WHERE id = ? AND trip_id = ? AND ${PackingService.VISIBLE_TO_ACTOR}`,
@@ -371,53 +373,53 @@ export class PackingService {
* Re-set who a "shared with specific people" item covers, and its visibility tier.
* Only the owner (bringer) may change this; a non-owner caller is rejected with null.
*/
setItemSharing(
async setItemSharing(
tripId: string | number,
id: string | number,
actingUserId: number,
visibility: PackingVisibility,
recipientIds: number[],
) {
const item = this.getItemInTrip(tripId, id, actingUserId);
const item = await this.getItemInTrip(tripId, id, actingUserId);
if (!item) return null;
// The owner controls sharing; an unowned legacy item is claimed by the actor.
if (item.owner_id != null && item.owner_id !== actingUserId) return { forbidden: true as const };
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('UPDATE packing_items SET is_private = ?, owner_id = COALESCE(owner_id, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?',
this.visibilityToPrivate(visibility), actingUserId, id);
this.db.run('DELETE FROM packing_item_recipients WHERE item_id = ?', id);
if (visibility === 'shared') {
const ins = this.db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
const owner = item.owner_id ?? actingUserId;
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of recipientIds) if (uid !== owner && roster.has(uid)) ins.run(id, uid);
}
// Leaving the Common tier drops any co-contributors (they only apply to Common).
if (visibility !== 'common') this.db.run('DELETE FROM packing_item_contributors WHERE item_id = ?', id);
});
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
/** "I can bring that too" — adds the user as a co-contributor on a Common item. */
addContributor(tripId: string | number, id: string | number, userId: number) {
const item = this.getItemInTrip(tripId, id, userId);
async addContributor(tripId: string | number, id: string | number, userId: number) {
const item = await this.getItemInTrip(tripId, id, userId);
if (!item || item.is_private !== 0) return null; // co-contribution is a Common-list concept
if (item.owner_id === userId) return null; // the bringer is already covering it
this.db.run("INSERT OR IGNORE INTO packing_item_contributors (item_id, user_id, status) VALUES (?, ?, 'accepted')", id, userId);
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
removeContributor(tripId: string | number, id: string | number, userId: number) {
const item = this.getItemInTrip(tripId, id, userId);
async removeContributor(tripId: string | number, id: string | number, userId: number) {
const item = await this.getItemInTrip(tripId, id, userId);
if (!item) return null;
this.db.run('DELETE FROM packing_item_contributors WHERE item_id = ? AND user_id = ?', id, userId);
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
/** True when the bag exists AND belongs to the trip — the referenced-id rule
* the FK cannot enforce (it only checks existence). */
private bagInTrip(tripId: string | number, bagId: number): boolean {
private async bagInTrip(tripId: string | number, bagId: number): Promise<boolean> {
return !!this.db.get('SELECT id FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
}
@@ -426,7 +428,7 @@ export class PackingService {
* owns, or one they belong to. Inheriting someone else's bag would drop the copy into
* their luggage and inflate their weight (#207).
*/
private bagForCloner(tripId: string | number, bagId: number | null, userId: number): number | null {
private async bagForCloner(tripId: string | number, bagId: number | null, userId: number): Promise<number | null> {
if (bagId == null) return null;
const bag = this.db.get<{ user_id: number | null }>('SELECT user_id FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
if (!bag) return null;
@@ -441,25 +443,25 @@ export class PackingService {
* Weight comes along — it is a property of the thing, and re-entering it by hand for
* every traveller was the whole complaint in #207.
*/
cloneItem(tripId: string | number, id: string | number, userId: number) {
const item = this.getItemInTrip(tripId, id, userId);
async cloneItem(tripId: string | number, id: string | number, userId: number) {
const item = await this.getItemInTrip(tripId, id, userId);
if (!item) return null;
return this.createItem(tripId, {
return await this.createItem(tripId, {
name: item.name,
category: item.category || undefined,
quantity: item.quantity,
weight_grams: item.weight_grams,
bag_id: this.bagForCloner(tripId, item.bag_id, userId),
bag_id: await this.bagForCloner(tripId, item.bag_id, userId),
visibility: 'personal',
}, userId);
}
deleteItem(tripId: string | number, id: string | number, actingUserId?: number) {
async deleteItem(tripId: string | number, id: string | number, actingUserId?: number) {
// Return the deleted row (not just a boolean) so callers can target the
// delete broadcast at the owner when the item was private (#858).
// Scoped to what the actor may see: trip membership alone used to be enough
// to delete another member's restricted item.
const item = this.getItemInTrip(tripId, id, actingUserId);
const item = await this.getItemInTrip(tripId, id, actingUserId);
if (!item) return null;
this.db.run('DELETE FROM packing_items WHERE id = ?', id);
@@ -468,14 +470,14 @@ export class PackingService {
// ── Bulk Import ────────────────────────────────────────────────────────────
bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
async bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?', tripId)!;
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const stmt = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const created: any[] = [];
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const item of items) {
if (!item.name?.trim()) continue;
const checked = item.checked ? 1 : 0;
@@ -524,7 +526,7 @@ export class PackingService {
* Keyed by bag id, with the unassigned pile under `null` — the same shape the
* "no bag" row on every packing surface needs.
*/
private bagWeightTotals(tripId: string | number): Map<number | null, number> {
private async bagWeightTotals(tripId: string | number): Promise<Map<number | null, number>> {
const rows = this.db.all<{ bag_id: number | null; total: number | null }>(`
SELECT bag_id, SUM(COALESCE(weight_grams, 0) * COALESCE(quantity, 1)) AS total
FROM packing_items
@@ -535,8 +537,8 @@ export class PackingService {
}
/** The weight of everything in the trip that is in no bag (#2191). */
unassignedWeightGrams(tripId: string | number): number {
return this.bagWeightTotals(tripId).get(null) ?? 0;
async unassignedWeightGrams(tripId: string | number): Promise<number> {
return (await this.bagWeightTotals(tripId)).get(null) ?? 0;
}
/**
@@ -547,16 +549,16 @@ export class PackingService {
* SUM…GROUP BY twice per request is not a cost worth paying for a nicer
* method list.
*/
listBagsWithWeights(tripId: string | number): { bags: unknown[]; unassigned_weight_grams: number } {
const totals = this.bagWeightTotals(tripId);
return { bags: this.decorateBags(tripId, totals), unassigned_weight_grams: totals.get(null) ?? 0 };
async listBagsWithWeights(tripId: string | number): Promise<{ bags: unknown[]; unassigned_weight_grams: number }> {
const totals = await this.bagWeightTotals(tripId);
return { bags: await this.decorateBags(tripId, totals), unassigned_weight_grams: totals.get(null) ?? 0 };
}
listBags(tripId: string | number) {
return this.decorateBags(tripId, this.bagWeightTotals(tripId));
async listBags(tripId: string | number) {
return await this.decorateBags(tripId, await this.bagWeightTotals(tripId));
}
private decorateBags(tripId: string | number, totals: Map<number | null, number>) {
private async decorateBags(tripId: string | number, totals: Map<number | null, number>) {
const bags = this.db.all<any>('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id', tripId);
const members = this.db.all<{ bag_id: number; user_id: number; username: string; avatar: string | null }>(`
SELECT bm.bag_id, bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
@@ -583,18 +585,18 @@ export class PackingService {
* "assigned to a bag", which is why the category and recipient writes below
* grew up without it.
*/
private tripRosterIds(tripId: string | number): Set<number> {
private async tripRosterIds(tripId: string | number): Promise<Set<number>> {
return this.db.rosterUserIds(tripId);
}
setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
async setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
const bag = this.db.get('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
if (!bag) return null;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('DELETE FROM packing_bag_members WHERE bag_id = ?', bagId);
const ins = this.db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
// Only real trip members may be bag members — never write an arbitrary account id.
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of userIds) if (roster.has(uid)) ins.run(bagId, uid);
});
const rows = this.db.all<{ user_id: number; username: string; avatar: string | null }>(`
@@ -605,7 +607,7 @@ export class PackingService {
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) {
async createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) {
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?', tripId)!;
const result = this.db.run('INSERT INTO packing_bags (trip_id, name, color, sort_order, weight_limit_grams) VALUES (?, ?, ?, ?, ?)',
tripId, data.name.trim(), data.color || '#6366f1', (maxOrder.max ?? -1) + 1, data.weight_limit_grams ?? null
@@ -613,7 +615,7 @@ export class PackingService {
return this.db.get('SELECT * FROM packing_bags WHERE id = ?', result.lastInsertRowid);
}
updateBag(
async updateBag(
tripId: string | number,
bagId: string | number,
data: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null },
@@ -623,7 +625,7 @@ export class PackingService {
if (!bag) return null;
// A bag may only be assigned to a real trip member; an off-roster id becomes unassigned.
const assignUser = data.user_id != null && this.tripRosterIds(tripId).has(data.user_id) ? data.user_id : null;
const assignUser = data.user_id != null && (await this.tripRosterIds(tripId)).has(data.user_id) ? data.user_id : null;
// weight_limit_grams follows the bodyKeys presence protocol like user_id:
// an omitted key leaves the limit unchanged, an explicit null clears it.
this.db.run(`UPDATE packing_bags SET
@@ -643,7 +645,7 @@ export class PackingService {
return this.db.get('SELECT b.*, COALESCE(u.display_name, u.username) as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?', bagId);
}
deleteBag(tripId: string | number, bagId: string | number): boolean {
async deleteBag(tripId: string | number, bagId: string | number): Promise<boolean> {
const bag = this.db.get('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
if (!bag) return false;
@@ -658,7 +660,7 @@ export class PackingService {
* can pick a template to apply. Management (create/edit/delete) stays admin-only
* under /api/admin/packing-templates.
*/
listTemplates() {
async listTemplates() {
return this.db.all<{ id: number; name: string; item_count: number }>(`
SELECT pt.id, pt.name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count
@@ -669,7 +671,7 @@ export class PackingService {
// ── Apply Template ─────────────────────────────────────────────────────────
applyTemplate(
async applyTemplate(
tripId: string | number,
templateId: string | number,
visibility: 'common' | 'personal' = 'common',
@@ -692,7 +694,7 @@ export class PackingService {
const insert = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, is_private, owner_id, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const added: any[] = [];
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const ti of templateItems) {
const result = insert.run(tripId, ti.name, ti.category, sortOrder++, isPrivate, owner);
const item = this.db.get('SELECT * FROM packing_items WHERE id = ?', result.lastInsertRowid);
@@ -705,7 +707,7 @@ export class PackingService {
// ── Save as Template ──────────────────────────────────────────────────────
saveAsTemplate(tripId: string | number, userId: number, templateName: string) {
async saveAsTemplate(tripId: string | number, userId: number, templateName: string) {
// A template is a durable, shareable artifact, so it may only capture what is
// the actor's to publish: the Common list plus their own items. It used to
// take every row in the trip, restricted ones included.
@@ -718,7 +720,7 @@ export class PackingService {
const categories = [...new Set(items.map(i => i.category || 'Other'))];
const templateId = this.db.transaction(() => {
const templateId = await this.uow.transactional(async () => {
const result = this.db.run('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)', templateName, userId);
const id = result.lastInsertRowid;
@@ -743,7 +745,7 @@ export class PackingService {
// ── Category Assignees ─────────────────────────────────────────────────────
getCategoryAssignees(tripId: string | number) {
async getCategoryAssignees(tripId: string | number) {
const rows = this.db.all<{ category_name: string; user_id: number; username: string; avatar: string | null }>(`
SELECT pca.category_name, pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_category_assignees pca
@@ -761,14 +763,14 @@ export class PackingService {
return assignees;
}
updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
this.db.transaction(() => {
async updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
await this.uow.transactional(async () => {
this.db.run('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?', tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = this.db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
// Same rule as setBagMembers: only people on this trip may be assigned.
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of userIds) if (roster.has(uid)) insert.run(tripId, categoryName, uid);
}
});
@@ -784,9 +786,9 @@ export class PackingService {
// ── Reorder ────────────────────────────────────────────────────────────────
reorderItems(tripId: string | number, orderedIds: number[]): void {
async reorderItems(tripId: string | number, orderedIds: number[]): Promise<void> {
const update = this.db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
this.db.transaction(() => {
await this.uow.transactional(async () => {
orderedIds.forEach((id, index) => {
update.run(index, id, tripId);
});
@@ -810,7 +812,7 @@ export class PackingService {
// packing_template_categories like the sibling category routes do.
/** An item looked up through its category, so :templateId actually scopes it. */
private scopedTemplateItem(templateId: string, itemId: string) {
private async scopedTemplateItem(templateId: string, itemId: string) {
return this.db.get(`
SELECT ti.* FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
@@ -818,7 +820,7 @@ export class PackingService {
`, itemId, templateId);
}
listPackingTemplates() {
async listPackingTemplates() {
return this.db.all(`
SELECT pt.*, u.username as created_by_name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
@@ -829,7 +831,7 @@ export class PackingService {
`);
}
getPackingTemplate(id: string) {
async getPackingTemplate(id: string) {
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', id);
if (!template) return { error: 'Template not found', status: 404 };
const categories = this.db.all('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id', id);
@@ -841,21 +843,21 @@ export class PackingService {
return { template, categories, items };
}
createPackingTemplate(name: string, createdBy: number) {
async createPackingTemplate(name: string, createdBy: number) {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
const result = this.db.run('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)', name.trim(), createdBy);
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', result.lastInsertRowid);
return { template };
}
updatePackingTemplate(id: string, data: { name?: string }) {
async updatePackingTemplate(id: string, data: { name?: string }) {
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', id);
if (!template) return { error: 'Template not found', status: 404 };
if (data.name?.trim()) this.db.run('UPDATE packing_templates SET name = ? WHERE id = ?', data.name.trim(), id);
return { template: this.db.get('SELECT * FROM packing_templates WHERE id = ?', id) };
}
deletePackingTemplate(id: string) {
async deletePackingTemplate(id: string) {
const template = this.db.get<{ name?: string }>('SELECT * FROM packing_templates WHERE id = ?', id);
if (!template) return { error: 'Template not found', status: 404 };
this.db.run('DELETE FROM packing_templates WHERE id = ?', id);
@@ -864,7 +866,7 @@ export class PackingService {
// Template categories
createTemplateCategory(templateId: string, name: string) {
async createTemplateCategory(templateId: string, name: string) {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', templateId);
if (!template) return { error: 'Template not found', status: 404 };
@@ -873,7 +875,7 @@ export class PackingService {
return { category: this.db.get('SELECT * FROM packing_template_categories WHERE id = ?', result.lastInsertRowid) };
}
updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
async updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (data.name?.trim())
@@ -881,7 +883,7 @@ export class PackingService {
return { category: this.db.get('SELECT * FROM packing_template_categories WHERE id = ?', catId) };
}
deleteTemplateCategory(templateId: string, catId: string) {
async deleteTemplateCategory(templateId: string, catId: string) {
const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
this.db.run('DELETE FROM packing_template_categories WHERE id = ?', catId);
@@ -890,7 +892,7 @@ export class PackingService {
// Template items
createTemplateItem(templateId: string, catId: string, name: string) {
async createTemplateItem(templateId: string, catId: string, name: string) {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
@@ -899,23 +901,23 @@ export class PackingService {
return { item: this.db.get('SELECT * FROM packing_template_items WHERE id = ?', result.lastInsertRowid) };
}
updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) {
const item = this.scopedTemplateItem(templateId, itemId);
async updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) {
const item = await this.scopedTemplateItem(templateId, itemId);
if (!item) return { error: 'Item not found', status: 404 };
if (data.name?.trim())
this.db.run('UPDATE packing_template_items SET name = ? WHERE id = ?', data.name.trim(), itemId);
return { item: this.db.get('SELECT * FROM packing_template_items WHERE id = ?', itemId) };
}
deleteTemplateItem(templateId: string, itemId: string) {
const item = this.scopedTemplateItem(templateId, itemId);
async deleteTemplateItem(templateId: string, itemId: string) {
const item = await this.scopedTemplateItem(templateId, itemId);
if (!item) return { error: 'Item not found', status: 404 };
this.db.run('DELETE FROM packing_template_items WHERE id = ?', itemId);
return {};
}
/** Fire-and-forget tag notification, mirroring the legacy dynamic import. */
notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void {
async notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): Promise<void> {
if (!Array.isArray(userIds) || userIds.length === 0) return;
// Injected, not a lazy import of the old notifications bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
+5 -5
View File
@@ -19,12 +19,12 @@ import { CurrentUser } from '../auth/current-user.decorator';
export class PhotosController {
constructor(private readonly photos: PhotosService) {}
private requireAccess(user: User, rawId: string): number {
private async requireAccess(user: User, rawId: string): Promise<number> {
const photoId = Number(rawId);
if (!Number.isFinite(photoId)) {
throw new HttpException({ error: 'Invalid photo ID' }, 400);
}
if (!this.photos.canAccess(user.id, photoId)) {
if (!(await this.photos.canAccess(user.id, photoId))) {
throw new HttpException({ error: 'Forbidden' }, 403);
}
return photoId;
@@ -32,19 +32,19 @@ export class PhotosController {
@Get(':id/thumbnail')
async thumbnail(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
const photoId = this.requireAccess(user, id);
const photoId = await this.requireAccess(user, id);
await this.photos.stream(res, user.id, photoId, 'thumbnail');
}
@Get(':id/original')
async original(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response, @Headers('range') range?: string): Promise<void> {
const photoId = this.requireAccess(user, id);
const photoId = await this.requireAccess(user, id);
await this.photos.stream(res, user.id, photoId, 'original', range);
}
@Get(':id/info')
async info(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
const photoId = this.requireAccess(user, id);
const photoId = await this.requireAccess(user, id);
const result = await this.photos.info(user.id, photoId);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
+1 -1
View File
@@ -15,7 +15,7 @@ export class PhotosService {
private readonly resolver: PhotoResolverService,
) {}
canAccess(userId: number, photoId: number): boolean {
canAccess(userId: number, photoId: number): Promise<boolean> {
return this.access.canAccessTrekPhoto(userId, photoId);
}
@@ -21,13 +21,13 @@ import type { TrekPhoto } from '../../types';
export class TrekPhotosRepository {
constructor(private readonly db: DatabaseService) {}
getOrCreate(
async getOrCreate(
provider: string,
assetId: string,
ownerId: number,
passphrase?: string,
mediaType: string = 'image',
): number {
): Promise<number> {
const existing = this.db.get<{ id: number }>(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?',
provider, assetId, ownerId,
@@ -46,14 +46,14 @@ export class TrekPhotosRepository {
return Number(res.lastInsertRowid);
}
getOrCreateLocal(
async getOrCreateLocal(
filePath: string,
thumbnailPath?: string | null,
width?: number | null,
height?: number | null,
mediaType: string = 'image',
durationMs?: number | null,
): number {
): Promise<number> {
const existing = this.db.get<{ id: number }>(
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?", filePath,
);
@@ -66,12 +66,12 @@ export class TrekPhotosRepository {
return Number(res.lastInsertRowid);
}
resolve(photoId: number): TrekPhoto | null {
async resolve(photoId: number): Promise<TrekPhoto | null> {
return this.db.get<TrekPhoto>('SELECT * FROM trek_photos WHERE id = ?', photoId) || null;
}
/** Retarget an existing row — used when a local photo is uploaded to Immich. */
setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): void {
async setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): Promise<void> {
this.db.run(
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?',
provider, assetId, ownerId, trekPhotoId,
@@ -82,7 +82,7 @@ export class TrekPhotosRepository {
* Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions
* that were already known — a re-generated thumbnail must not blank them.
*/
recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): void {
async recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): Promise<void> {
this.db.run(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?',
thumbnailPath, width, height, photoId,
@@ -100,10 +100,10 @@ export class TrekPhotosRepository {
*
* A photo with neither is the normal case, not a failure.
*/
recordCaptureMetadata(
async recordCaptureMetadata(
photoId: number,
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
): void {
): Promise<void> {
const { takenAt = null, lat = null, lng = null } = meta;
if (takenAt == null && lat == null && lng == null) return;
// Coordinates are stored as a pair or not at all — a lone latitude is not a
@@ -123,7 +123,7 @@ export class TrekPhotosRepository {
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
*/
deleteIfOrphan(photoId: number): void {
async deleteIfOrphan(photoId: number): Promise<void> {
const stillUsed = this.db.get(`
SELECT 1 FROM trip_photos WHERE photo_id = ?
UNION ALL
+4 -4
View File
@@ -25,7 +25,7 @@ export class TripShareController {
constructor(private readonly share: ShareService) {}
private async requireManage(tripId: string, user: User) {
const trip = this.share.verifyTripAccess(tripId, user.id);
const trip = await this.share.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
@@ -42,7 +42,7 @@ export class TripShareController {
@Res({ passthrough: true }) res: Response,
) {
await this.requireManage(tripId, user);
const result = this.share.createOrUpdate(tripId, user.id, {
const result = await this.share.createOrUpdate(tripId, user.id, {
share_map: body.share_map,
share_bookings: body.share_bookings,
share_packing: body.share_packing,
@@ -62,14 +62,14 @@ export class TripShareController {
// trip while signed in; it does not let them hand out a copy that works
// without an account and outlives their membership.
await this.requireManage(tripId, user);
const info = this.share.get(tripId);
const info = await this.share.get(tripId);
return info ? info : { token: null };
}
@Delete()
async remove(@CurrentUser() user: User, @Param('tripId') tripId: string) {
await this.requireManage(tripId, user);
this.share.remove(tripId);
await this.share.remove(tripId);
return { success: true };
}
}
+6 -6
View File
@@ -40,9 +40,9 @@ export class ShareMcp {
// requires share_manage on every verb including this one: the payload is the
// token itself, and a token is an anonymous copy of the trip. Leaving this
// one on membership alone would just move the same hole to MCP.
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
const link = this.share.get(String(tripId));
const link = await this.share.get(String(tripId));
return ok({ link });
}
@@ -67,11 +67,11 @@ export class ShareMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, {
const { token, created } = await this.share.createOrUpdate(String(tripId), ctx.userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
});
return ok({ token, created });
@@ -88,9 +88,9 @@ export class ShareMcp {
})
async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
this.share.remove(String(tripId));
await this.share.remove(String(tripId));
return ok({ success: true });
}
}
+9 -7
View File
@@ -7,6 +7,7 @@ import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility';
import { SettingsService } from '../settings/settings.service';
import { UnitOfWork } from '../database/unit-of-work';
import type { User } from '../../types';
type Trip = TripAccess;
@@ -155,9 +156,10 @@ export class ShareService {
private readonly permissions: PermissionsService,
private readonly queryHelpers: QueryHelpersService,
private readonly photoCache: PlacePhotoCacheService,
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string, userId: number) {
async verifyTripAccess(tripId: string, userId: number) {
return this.dbs.canAccessTrip(tripId, userId);
}
@@ -174,7 +176,7 @@ export class ShareService {
* before the expires_at migration keep NULL until touched and remain valid
* indefinitely; an explicit update moves them onto the TTL.
*/
createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): { token: string; created: boolean } {
async createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): Promise<{ token: string; created: boolean }> {
const {
share_map = true,
share_bookings = true,
@@ -184,7 +186,7 @@ export class ShareService {
} = permissions;
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
return this.dbs.transaction(() => {
return await this.uow.transactional(async () => {
const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId);
if (existing) {
this.dbs.run(
@@ -206,7 +208,7 @@ export class ShareService {
/**
* Returns share token info for a trip, or null if no share link exists.
*/
get(tripId: string): ShareTokenInfo | null {
async get(tripId: string): Promise<ShareTokenInfo | null> {
const row = this.dbs.get<any>('SELECT * FROM share_tokens WHERE trip_id = ?', tripId);
if (!row) return null;
return {
@@ -223,7 +225,7 @@ export class ShareService {
/**
* Deletes the share token for a trip.
*/
remove(tripId: string): void {
async remove(tripId: string): Promise<void> {
this.dbs.run('DELETE FROM share_tokens WHERE trip_id = ?', tripId);
}
@@ -243,7 +245,7 @@ export class ShareService {
* route from these, and a trip with forty bookings is not forty round trips
* to the database.
*/
private publicEndpointsByReservation(tripId: number): Map<number, Array<Record<string, unknown>>> {
private async publicEndpointsByReservation(tripId: number): Promise<Map<number, Array<Record<string, unknown>>>> {
const rows = this.dbs.all<{ reservation_id: number } & Record<string, unknown>>(
`SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, e.timezone, e.local_date, e.local_time
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
@@ -380,7 +382,7 @@ export class ShareService {
// an imported ticket keeps its seat and its record locator. A public
// link shows what the booking is, not what it would take to change it
// (#2320). The endpoints ride along, since the map draws from them.
const endpoints = this.publicEndpointsByReservation(tripId);
const endpoints = await this.publicEndpointsByReservation(tripId);
reservations = this.dbs.all<any>(
`SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r
WHERE r.trip_id = ? AND ${publicReservationSql('r')}
@@ -72,7 +72,7 @@ export class TripReadModelService {
// Thread the viewer so another member's private/personal packing items (#858)
// stay hidden — without it listItems returns the UNFILTERED list.
const packingItems = this.packing.listItems(tripId, viewerUserId);
const packingItems = await this.packing.listItems(tripId, viewerUserId);
const packing = {
items: packingItems,
total: packingItems.length,
@@ -106,7 +106,7 @@ export class TripReadModelService {
places: await this.places.list(String(tripId), {}),
// Scope to the requesting member so other members' private packing items
// (#858) never land in this viewer's offline cache.
packingItems: this.packing.listItems(tripId, viewerId),
packingItems: await this.packing.listItems(tripId, viewerId),
todoItems: await this.todo.listItems(tripId),
budgetItems: await this.budget.listBudgetItems(tripId),
reservations: this.reservations.list(tripId),
+2 -2
View File
@@ -77,11 +77,11 @@ export class TripPromptsMcp {
when: packingAddonOn,
})
async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) {
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
// Hide other members' private items (#858) from the requesting user.
const items = this.packing.listItems(tripId, ctx.userId);
const items = await this.packing.listItems(tripId, ctx.userId);
if (!items.length) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] };
}
+2 -2
View File
@@ -150,7 +150,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
const queryHelpersService = new QueryHelpersService(dbService);
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService, await createTestUnitOfWork(dbService.connection));
const todoService = new TodoService(dbService, permissionsService, realtimeService, await createTestUnitOfWork(dbService.connection));
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection));
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbService.connection));
// Exactly one instance, shared by maps, places and share: its stampede guard
// and its on-disk set only work if all three readers see the same maps.
@@ -233,7 +233,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService, await createTestUnitOfWork(dbService.connection)), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(dbService.connection)), authService, guards),
new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards),
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)),
new MapsMcp(mapsService),
+1 -1
View File
@@ -94,7 +94,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const addons = new AddonsService(dbs);
const queryHelpers = new QueryHelpersService(dbs);
const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection));
const packing = new PackingService(dbs, permissions, realtime, notificationsStub());
const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage);
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection));
const vacay = new VacayService(dbs, realtime, notificationsStub());
@@ -1182,17 +1182,17 @@ const deleteTrekPhotoIfOrphan = (id: number) => trekPhotos.deleteIfOrphan(id);
import { decrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
describe('trek_photos passphrase healing (SYNO-090)', () => {
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => {
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', async () => {
const { user } = createUser(testDb);
const wrongPass = 'wrong-passphrase';
const correctPass = 'correct-passphrase';
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string };
expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass);
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
expect(id2).toBe(id1);
const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
expect(decrypt_api_key(row2.passphrase)).toBe(correctPass);
@@ -1200,28 +1200,28 @@ describe('trek_photos passphrase healing (SYNO-090)', () => {
});
describe('trek_photos orphan cleanup (SYNO-091)', () => {
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => {
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
const trekPhotoId = await getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
testDb.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
).run(trip.id, user.id, trekPhotoId);
// Still referenced — must not be deleted.
deleteTrekPhotoIfOrphan(trekPhotoId);
await deleteTrekPhotoIfOrphan(trekPhotoId);
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined();
// Remove the reference, then orphan-cleanup should delete the trek_photos row.
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId);
deleteTrekPhotoIfOrphan(trekPhotoId);
await deleteTrekPhotoIfOrphan(trekPhotoId);
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined();
});
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => {
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
@@ -1230,18 +1230,18 @@ describe('trek_photos orphan cleanup (SYNO-091)', () => {
const secondPass = 'second-passphrase';
// Add with wrong passphrase, then remove (simulating the bug scenario).
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
testDb.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
).run(trip.id, user.id, id1);
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1);
deleteTrekPhotoIfOrphan(id1);
await deleteTrekPhotoIfOrphan(id1);
// trek_photos row should be gone.
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined();
// Re-add with the correct passphrase.
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
});
+1 -1
View File
@@ -133,7 +133,7 @@ beforeAll(async () => {
addonsStub,
promptGuards,
);
promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub());
promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(promptDbs().connection));
packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards);
budgetMcp = new BudgetMcp(
new BudgetService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(promptDbs().connection)),
@@ -19,17 +19,17 @@ const writeAudit = vi.fn();
function controller(over: Partial<PackingService> = {}) {
const packing = {
listPackingTemplates: vi.fn(() => [{ id: 1 }]),
getPackingTemplate: vi.fn(() => ({ template: { id: 1 } })),
createPackingTemplate: vi.fn(() => ({ template: { id: 9 } })),
updatePackingTemplate: vi.fn(() => ({ template: { id: 1 } })),
deletePackingTemplate: vi.fn(() => ({ name: 'Beach' })),
createTemplateCategory: vi.fn(() => ({ category: { id: 2 } })),
updateTemplateCategory: vi.fn(() => ({ category: { id: 2 } })),
deleteTemplateCategory: vi.fn(() => ({ ok: true })),
createTemplateItem: vi.fn(() => ({ item: { id: 3 } })),
updateTemplateItem: vi.fn(() => ({ item: { id: 3 } })),
deleteTemplateItem: vi.fn(() => ({ ok: true })),
listPackingTemplates: vi.fn(async () => [{ id: 1 }]),
getPackingTemplate: vi.fn(async () => ({ template: { id: 1 } })),
createPackingTemplate: vi.fn(async () => ({ template: { id: 9 } })),
updatePackingTemplate: vi.fn(async () => ({ template: { id: 1 } })),
deletePackingTemplate: vi.fn(async () => ({ name: 'Beach' })),
createTemplateCategory: vi.fn(async () => ({ category: { id: 2 } })),
updateTemplateCategory: vi.fn(async () => ({ category: { id: 2 } })),
deleteTemplateCategory: vi.fn(async () => ({ ok: true })),
createTemplateItem: vi.fn(async () => ({ item: { id: 3 } })),
updateTemplateItem: vi.fn(async () => ({ item: { id: 3 } })),
deleteTemplateItem: vi.fn(async () => ({ ok: true })),
...over,
} as unknown as PackingService;
return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing };
@@ -45,9 +45,9 @@ function offContract<K extends keyof PackingService>(name: K, impl: () => unknow
return { [name]: vi.fn(impl) } as unknown as Partial<PackingService>;
}
const thrown = (run: () => unknown) => {
const thrown = async (run: () => unknown) => {
try {
run();
await run();
return null;
} catch (e) {
return e instanceof HttpException ? { status: e.getStatus(), body: e.getResponse() } : e;
@@ -59,18 +59,18 @@ describe('AdminPackingTemplatesController', () => {
vi.clearAllMocks();
});
it('PACKTPL-001 list wraps the service result in the { templates } envelope', () => {
expect(controller().c.list()).toEqual({ templates: [{ id: 1 }] });
it('PACKTPL-001 list wraps the service result in the { templates } envelope', async () => {
expect(await controller().c.list()).toEqual({ templates: [{ id: 1 }] });
});
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', () => {
const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) });
expect(thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', async () => {
const { c } = controller({ getPackingTemplate: vi.fn(async () => ({ error: 'not found', status: 404 })) });
expect(await thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
});
it('PACKTPL-003 an error without a status defaults to 400', () => {
const { c } = controller(offContract('createTemplateCategory', () => ({ error: 'name required' })));
expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
it('PACKTPL-003 an error without a status defaults to 400', async () => {
const { c } = controller(offContract('createTemplateCategory', async () => ({ error: 'name required' })));
expect(await thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
});
it('PACKTPL-004 create audits with the new template id', async () => {
@@ -90,26 +90,26 @@ describe('AdminPackingTemplatesController', () => {
);
});
it('PACKTPL-006 the nested category and item routes forward their ids in order', () => {
it('PACKTPL-006 the nested category and item routes forward their ids in order', async () => {
const { c, packing } = controller();
c.createCategory('1', { name: 'Clothes' });
await c.createCategory('1', { name: 'Clothes' });
expect(packing.createTemplateCategory).toHaveBeenCalledWith('1', 'Clothes');
c.updateCategory('1', '2', { name: 'Warm' });
await c.updateCategory('1', '2', { name: 'Warm' });
expect(packing.updateTemplateCategory).toHaveBeenCalledWith('1', '2', { name: 'Warm' });
expect(c.deleteCategory('1', '2')).toEqual({ success: true });
c.createItem('1', '2', { name: 'Socks' });
expect(await c.deleteCategory('1', '2')).toEqual({ success: true });
await c.createItem('1', '2', { name: 'Socks' });
expect(packing.createTemplateItem).toHaveBeenCalledWith('1', '2', 'Socks');
c.updateItem('1', '3', { name: 'Wool socks' });
await c.updateItem('1', '3', { name: 'Wool socks' });
expect(packing.updateTemplateItem).toHaveBeenCalledWith('1', '3', { name: 'Wool socks' });
expect(c.deleteItem('1', '3')).toEqual({ success: true });
expect(await c.deleteItem('1', '3')).toEqual({ success: true });
});
it('PACKTPL-007 the delete routes answer { success: true } rather than the service payload', () => {
it('PACKTPL-007 the delete routes answer { success: true } rather than the service payload', async () => {
// The client branches on `success`; leaking the raw service result here would be a
// silent shape change for every caller.
const { c } = controller();
expect(c.deleteCategory('1', '2')).toEqual({ success: true });
expect(c.deleteItem('1', '3')).toEqual({ success: true });
expect(await c.deleteCategory('1', '2')).toEqual({ success: true });
expect(await c.deleteItem('1', '3')).toEqual({ success: true });
});
it('PACKTPL-008 the class is listed in its module controllers', () => {
+22 -22
View File
@@ -91,27 +91,27 @@ beforeEach(() => {
afterAll(() => testDb.close());
describe('getImmichCredentials', () => {
it('IMMICH-001: returns null when the user row is missing', () => {
expect(svc.getImmichCredentials(999)).toBeNull();
it('IMMICH-001: returns null when the user row is missing', async () => {
expect(await svc.getImmichCredentials(999)).toBeNull();
});
it('IMMICH-002: returns null without a URL', () => {
it('IMMICH-002: returns null without a URL', async () => {
seedUser(2, null, 'key');
expect(svc.getImmichCredentials(2)).toBeNull();
expect(await svc.getImmichCredentials(2)).toBeNull();
});
it('IMMICH-003: returns null without an API key', () => {
it('IMMICH-003: returns null without an API key', async () => {
seedUser(3, 'https://immich.test', null);
expect(svc.getImmichCredentials(3)).toBeNull();
expect(await svc.getImmichCredentials(3)).toBeNull();
});
it('IMMICH-004: returns null when the stored key cannot be decrypted', () => {
it('IMMICH-004: returns null when the stored key cannot be decrypted', async () => {
decryptMock.mockReturnValue(null);
expect(svc.getImmichCredentials(USER)).toBeNull();
expect(await svc.getImmichCredentials(USER)).toBeNull();
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
it('IMMICH-005: returns the decrypted pair otherwise', async () => {
expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
});
});
@@ -124,20 +124,20 @@ describe('isValidAssetId', () => {
});
describe('getConnectionSettings / setImmichAutoUpload', () => {
it('IMMICH-007: reports connected with the URL when configured', () => {
expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
it('IMMICH-007: reports connected with the URL when configured', async () => {
expect(await svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
});
it('IMMICH-008: reports an empty URL and not connected when it is not', () => {
it('IMMICH-008: reports an empty URL and not connected when it is not', async () => {
seedUser(4, null, null);
expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
expect(await svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
});
it('IMMICH-009: surfaces the auto-upload flag both ways', () => {
svc.setImmichAutoUpload(USER, true);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(true);
svc.setImmichAutoUpload(USER, false);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(false);
it('IMMICH-009: surfaces the auto-upload flag both ways', async () => {
await svc.setImmichAutoUpload(USER, true);
expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(true);
await svc.setImmichAutoUpload(USER, false);
expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(false);
});
});
@@ -148,14 +148,14 @@ describe('saveImmichSettings', () => {
const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null);
expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' });
expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test');
expect((await svc.getImmichCredentials(USER))!.immich_url).toBe('https://immich.test');
});
it('IMMICH-011: stores a trimmed URL and reports plain success', async () => {
const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null);
expect(result).toEqual({ success: true });
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
});
it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => {
@@ -173,7 +173,7 @@ describe('saveImmichSettings', () => {
expect(result).toEqual({ success: true });
expect(checkSsrf).not.toHaveBeenCalled();
expect(svc.getImmichCredentials(USER)).toBeNull();
expect(await svc.getImmichCredentials(USER)).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -86,11 +86,11 @@ describe('JourneyController', () => {
expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', () => {
it('provider-photos: batch, single 400/403, success', async () => {
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
expect(ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(await ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(await thrownAsync(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(await thrownAsync(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
@@ -136,21 +136,21 @@ describe('JourneyController', () => {
expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]);
});
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => {
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', async () => {
const add = vi.fn().mockReturnValue({ id: 1 });
ctl(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
await ctl(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video');
expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image');
const addOne = vi.fn().mockReturnValue({ id: 2 });
ctl(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
await ctl(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video');
// Entry path mirrors the gallery path.
const eAdd = vi.fn().mockReturnValue({ id: 3 });
ctl(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
await ctl(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video');
const eOne = vi.fn().mockReturnValue({ id: 4 });
ctl(svc({ addProviderPhoto: eOne } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
await ctl(svc({ addProviderPhoto: eOne } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
@@ -264,12 +264,12 @@ describe('JourneyController', () => {
expect(await ctl(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
});
it('provider-photos batch passes the passphrase through when present', () => {
it('provider-photos batch passes the passphrase through when present', async () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
ctl(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
await ctl(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
expect(ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
expect(await ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
@@ -291,14 +291,14 @@ describe('JourneyController', () => {
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
it('gallery provider-photos: batch (with passphrase), single 400/403, success', async () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
const batch = ctl(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(await batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
expect(thrown(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
expect(await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(await thrownAsync(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(await ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then removes the object when present', async () => {
@@ -57,12 +57,14 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { mapDbError, pipeAsset } from '../../../src/nest/memories/memories.helpers';
import { mapDbError, pipeAsset, type ServiceResult } from '../../../src/nest/memories/memories.helpers';
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
const access = new MemoriesAccessService(new DatabaseService(testDb));
const getAlbumIdFromLink = access.getAlbumIdFromLink.bind(access);
// A typed forwarder, not a `.bind` alias: a bound alias is typed `any`, which
// hides a missing `await` from tsc and from all three lint rules.
const getAlbumIdFromLink = (...a: Parameters<MemoriesAccessService['getAlbumIdFromLink']>) => access.getAlbumIdFromLink(...a);
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(() => {
@@ -82,7 +84,7 @@ afterAll(() => {
// ── mapDbError ────────────────────────────────────────────────────────────────
describe('mapDbError', () => {
it('MEM-HELPERS-001: returns 409 for unique constraint error', () => {
it('MEM-HELPERS-001: returns 409 for unique constraint error', async () => {
const err = new Error('UNIQUE constraint failed: users.email');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
@@ -90,14 +92,14 @@ describe('mapDbError', () => {
expect(result.error.message).toBe('Resource already exists');
});
it('MEM-HELPERS-002: returns 409 for generic constraint error', () => {
it('MEM-HELPERS-002: returns 409 for generic constraint error', async () => {
const err = new Error('constraint violation');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(409);
});
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => {
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', async () => {
const err = new Error('Something went wrong');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
@@ -105,7 +107,7 @@ describe('mapDbError', () => {
expect(result.error.message).toBe('Something went wrong');
});
it('MEM-HELPERS-004: returns 500 for generic DB error', () => {
it('MEM-HELPERS-004: returns 500 for generic DB error', async () => {
const err = new Error('disk I/O error');
const result = mapDbError(err, 'fallback');
expect(result.error.status).toBe(500);
@@ -114,24 +116,34 @@ describe('mapDbError', () => {
// ── getAlbumIdFromLink ────────────────────────────────────────────────────────
/**
* The failure arm of a ServiceResult. The assertions below used to read
* `result.error` through a `.bind` alias that typed the whole thing `any`; the
* typed forwarder narrows properly instead of casting.
*/
function failureOf(result: ServiceResult<unknown>): { message: string; status: number } {
if (!('error' in result)) throw new Error('expected a failing ServiceResult');
return result.error;
}
describe('getAlbumIdFromLink', () => {
it('MEM-HELPERS-005: returns 404 when trip access is denied', () => {
const result = getAlbumIdFromLink('9999', 'link-1', 1);
it('MEM-HELPERS-005: returns 404 when trip access is denied', async () => {
const result = await getAlbumIdFromLink('9999', 'link-1', 1);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
expect(failureOf(result).status).toBe(404);
});
it('MEM-HELPERS-006: returns 404 when album link is not found', () => {
it('MEM-HELPERS-006: returns 404 when album link is not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
const result = await getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
expect(result.error.message).toBe('Album link not found');
expect(failureOf(result).status).toBe(404);
expect(failureOf(result).message).toBe('Album link not found');
});
it('MEM-HELPERS-007: returns album_id when link exists', () => {
it('MEM-HELPERS-007: returns album_id when link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -141,7 +153,7 @@ describe('getAlbumIdFromLink', () => {
).run(trip.id, user.id, 'immich', 'album-123', 'My Album');
const linkId = ins.lastInsertRowid;
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
const result = await getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
expect(result.success).toBe(true);
expect((result as any).data).toBe('album-123');
});
@@ -308,50 +320,50 @@ function makeJourney(userId: number): number {
}
describe('canAccessUserPhoto', () => {
it('MEM-ACCESS-001: the owner always passes, with no lookup needed', () => {
expect(access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true);
it('MEM-ACCESS-001: the owner always passes, with no lookup needed', async () => {
expect(await access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true);
});
it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', () => {
it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true);
expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true);
});
it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', () => {
it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-private', 'immich', 0);
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false);
});
it('MEM-ACCESS-004: a stranger is refused even for a shared asset', () => {
it('MEM-ACCESS-004: a stranger is refused even for a shared asset', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const trip = createTrip(testDb, owner.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false);
});
it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', () => {
it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'same-id', 'immich');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false);
expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false);
});
it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', () => {
it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', async () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
@@ -361,10 +373,10 @@ describe('canAccessUserPhoto', () => {
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true);
expect(await access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true);
});
it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', () => {
it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const journeyId = makeJourney(owner.id);
@@ -373,31 +385,31 @@ describe('canAccessUserPhoto', () => {
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false);
});
it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', () => {
it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
expect(access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false);
});
});
describe('canAccessTrekPhoto', () => {
it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', () => {
expect(access.canAccessTrekPhoto(1, 999999)).toBe(false);
it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', async () => {
expect(await access.canAccessTrekPhoto(1, 999999)).toBe(false);
});
it('MEM-ACCESS-011: the owner passes', () => {
it('MEM-ACCESS-011: the owner passes', async () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'own', ?)"
).run(user.id).lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(true);
});
it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', () => {
it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const { user: stranger } = createUser(testDb, { username: 'stranger' });
@@ -405,20 +417,20 @@ describe('canAccessTrekPhoto', () => {
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
const photoId = shareInTrip(trip.id, owner.id, 'trek-shared');
expect(access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
expect(await access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
});
it('MEM-ACCESS-013: the trip owner passes without a trip_members row', () => {
it('MEM-ACCESS-013: the trip owner passes without a trip_members row', async () => {
const { user: owner } = createUser(testDb);
const { user: uploader } = createUser(testDb, { username: 'uploader' });
const trip = createTrip(testDb, owner.id);
const photoId = shareInTrip(trip.id, uploader.id, 'owner-path');
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
});
it('MEM-ACCESS-014: a journey contributor passes', () => {
it('MEM-ACCESS-014: a journey contributor passes', async () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
@@ -428,10 +440,10 @@ describe('canAccessTrekPhoto', () => {
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true);
});
it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', () => {
it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const journeyId = makeJourney(owner.id);
@@ -440,16 +452,16 @@ describe('canAccessTrekPhoto', () => {
).run().lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false);
expect(await access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false);
});
it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', () => {
it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', async () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'orphan.jpg')"
).run().lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
});
});
@@ -195,19 +195,19 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif
});
describe('DELETE /trips/:tripId/album-links/:linkId', () => {
it('removes the link on success', () => {
it('removes the link on success', async () => {
const removeAlbumLink = vi.fn().mockReturnValue({ data: {} });
const svc = makeService({ removeAlbumLink });
const res = makeRes();
new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
await new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
expect(removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7);
expect(res.json).toHaveBeenCalledWith({ success: true });
});
it('maps the error envelope', () => {
it('maps the error envelope', async () => {
const svc = makeService({ removeAlbumLink: vi.fn().mockReturnValue({ error: { status: 404, message: 'Link not found' } }) });
const res = makeRes();
new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
await new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Link not found' });
});
@@ -98,13 +98,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
await svc.setTripPhotoSharing('5', 7, 9, false);
expect(unified.setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, false);
svc.removeTripPhoto('5', 7, 9);
await svc.removeTripPhoto('5', 7, 9);
expect(unified.removeTripPhoto).toHaveBeenCalledWith('5', 7, 9);
await svc.listTripAlbumLinks('5', 7);
expect(unified.listTripAlbumLinks).toHaveBeenCalledWith('5', 7);
svc.removeAlbumLink('5', 'l1', 7);
await svc.removeAlbumLink('5', 'l1', 7);
expect(unified.removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7);
});
@@ -117,13 +117,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
});
it('immich methods delegate', async () => {
svc.immichGetConnectionSettings(7);
await svc.immichGetConnectionSettings(7);
expect(immich.getConnectionSettings).toHaveBeenCalledWith(7);
await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4');
expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4');
svc.immichSetAutoUpload(7, true);
await svc.immichSetAutoUpload(7, true);
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true);
await svc.immichGetConnectionStatus(7);
+104 -104
View File
@@ -51,9 +51,9 @@ function makeService(overrides: Partial<PackingService> = {}): PackingService {
return svc;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
fn();
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
@@ -66,9 +66,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
// trip-access.guard.test.ts with the check itself.
describe('PackingController (parity with the legacy /api/trips/:tripId/packing route)', () => {
it('GET / returns items for an accessible trip', () => {
it('GET / returns items for an accessible trip', async () => {
const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<PackingService>);
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
expect(await new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
});
describe('POST / (create)', () => {
@@ -78,97 +78,97 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// bypass parameter pipes, so that path is covered by the e2e suite and
// the schema spec in @trek/shared.
it('creates an item (owned by the creator) and broadcasts a Common item to the room', () => {
it('creates an item (owned by the creator) and broadcasts a Common item to the room', async () => {
// Common item (is_private falsy) → viewersOf null → whole-room broadcast.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 });
const broadcast = vi.fn();
const svc = makeService({ createItem, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } });
expect(await new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } });
// Stamps the creator as owner (#858) and routes the broadcast to viewers.
expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ name: 'Socks' }), user.id);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks', is_private: 0 } }, 'sock');
});
it('pings the room that bag weights moved after a create (#2191)', () => {
it('pings the room that bag weights moved after a create (#2191)', async () => {
// The totals are summed server-side, so nothing else tells the other
// members' screens to re-read them.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 });
const broadcastBagTotals = vi.fn();
const svc = makeService({ createItem, broadcastBagTotals } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Socks', weight_grams: 250 }, 'sock');
await new PackingController(svc).create(user, '5', { name: 'Socks', weight_grams: 250 }, 'sock');
expect(broadcastBagTotals).toHaveBeenCalledWith('5');
});
it('pings after a delete, and after a bag delete that orphans its items (#2191)', () => {
it('pings after a delete, and after a bag delete that orphans its items (#2191)', async () => {
const deleteItem = vi.fn().mockReturnValue({ id: 9, is_private: 0 });
const broadcastBagTotals = vi.fn();
const svc = makeService({ deleteItem, broadcastBagTotals } as Partial<PackingService>);
new PackingController(svc).remove(user, '5', '9', 'sock');
await new PackingController(svc).remove(user, '5', '9', 'sock');
expect(broadcastBagTotals).toHaveBeenCalledWith('5');
// bag_id is ON DELETE SET NULL: the bag's items land in the unassigned pile.
const deleteBag = vi.fn().mockReturnValue(true);
const bagPing = vi.fn();
const bagSvc = makeService({ deleteBag, broadcastBagTotals: bagPing } as Partial<PackingService>);
new PackingController(bagSvc).deleteBag(user, '5', '7', 'sock');
await new PackingController(bagSvc).deleteBag(user, '5', '7', 'sock');
expect(bagPing).toHaveBeenCalledWith('5');
});
it('does NOT ping when the update cannot move a weight (#2191)', () => {
it('does NOT ping when the update cannot move a weight (#2191)', async () => {
// Checking an item off is the most frequent packing write there is, and
// every ping costs each connected client a listBags round trip.
const updateItem = vi.fn().mockReturnValue({ id: 9, is_private: 0 });
const broadcastBagTotals = vi.fn();
const svc = makeService({ updateItem, broadcastBagTotals } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { checked: true }, 'sock');
await new PackingController(svc).update(user, '5', '9', { checked: true }, 'sock');
expect(broadcastBagTotals).not.toHaveBeenCalled();
new PackingController(svc).update(user, '5', '9', { quantity: 3 }, 'sock');
await new PackingController(svc).update(user, '5', '9', { quantity: 3 }, 'sock');
expect(broadcastBagTotals).toHaveBeenCalledWith('5');
});
it('routes a Shared item create only to the owner + recipients (#858)', () => {
it('routes a Shared item create only to the owner + recipients (#858)', async () => {
const item = { id: 9, name: 'Power bank', is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const createItem = vi.fn().mockReturnValue(item);
const broadcastToViewers = vi.fn();
const svc = makeService({ createItem, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock');
await new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1, 2], 'sock');
});
it('forwards weight_grams, bag_id and quantity to the service (#2154)', () => {
it('forwards weight_grams, bag_id and quantity to the service (#2154)', async () => {
// The create route used to hand only the six legacy fields through, so
// the values a caller sent vanished into a 201.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Tent', is_private: 0 });
const svc = makeService({ createItem, broadcast: vi.fn() } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Tent', weight_grams: 250, bag_id: 3, quantity: 3 }, 'sock');
await new PackingController(svc).create(user, '5', { name: 'Tent', weight_grams: 250, bag_id: 3, quantity: 3 }, 'sock');
expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ weight_grams: 250, bag_id: 3, quantity: 3 }), user.id);
});
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', () => {
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', async () => {
// Body validation error, so 400 — the 404 'Bag not found' stays with the
// /bags/:bagId path routes.
const broadcast = vi.fn();
const svc = makeService({ createItem: vi.fn().mockReturnValue({ invalidBag: true }), broadcast } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).create(user, '5', { name: 'Tent', bag_id: 999 }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).create(user, '5', { name: 'Tent', bag_id: 999 }))).toEqual({
status: 400, body: { error: 'Bag not found' },
});
expect(broadcast).not.toHaveBeenCalled();
});
});
it('GET / lists items for the trip, scoped to the viewer (#858)', () => {
it('GET / lists items for the trip, scoped to the viewer (#858)', async () => {
const listItems = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
const svc = makeService({ listItems } as Partial<PackingService>);
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] });
expect(await new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] });
expect(listItems).toHaveBeenCalledWith('5', user.id);
});
describe('POST /import', () => {
it('400 when items is an empty array (bespoke check, schema permits [])', () => {
it('400 when items is an empty array (bespoke check, schema permits [])', async () => {
const svc = makeService();
expect(thrown(() => new PackingController(svc).importItems(user, '5', { items: [] }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).importItems(user, '5', { items: [] }))).toEqual({
status: 400, body: { error: 'items must be a non-empty array' },
});
});
@@ -178,11 +178,11 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// path is covered by the e2e suite and the schema spec in @trek/shared.
it('imports (owned by the importer) and broadcasts per item', () => {
it('imports (owned by the importer) and broadcasts per item', async () => {
const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
const broadcastItem = vi.fn();
const svc = makeService({ bulkImport, broadcastItem } as Partial<PackingService>);
const res = new PackingController(svc).importItems(user, '5', { items: [{ name: 'a' }, { name: 'b' }] }, 'sock');
const res = await new PackingController(svc).importItems(user, '5', { items: [{ name: 'a' }, { name: 'b' }] }, 'sock');
expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }], count: 2 });
expect(bulkImport).toHaveBeenCalledWith('5', [{ name: 'a' }, { name: 'b' }], user.id);
expect(broadcastItem).toHaveBeenCalledTimes(2);
@@ -190,18 +190,18 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('PUT /:id (update)', () => {
it('404 when the item is missing', () => {
it('404 when the item is missing', async () => {
const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Item not found' },
});
});
it('updates, forwards changed keys + acting user, and broadcasts (stays public)', () => {
it('updates, forwards changed keys + acting user, and broadcasts (stays public)', async () => {
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X' });
const broadcast = vi.fn();
const svc = makeService({ updateItem, broadcast } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock');
await new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock');
// acting user id is forwarded so privatizing an unowned item can stamp the
// owner (#858); checked is normalized to the 0/1 the SQL binds.
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'X', checked: 1 }), ['name', 'checked'], undefined, user.id);
@@ -209,56 +209,56 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X' } }, 'sock');
});
it('keeps a private update scoped to the owner (#858)', () => {
it('keeps a private update scoped to the owner (#858)', async () => {
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 });
const broadcast = vi.fn();
const broadcastItem = vi.fn();
// Was already private before the change → owner-only update, no room broadcast.
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 });
const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock');
await new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock');
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X', is_private: 1, owner_id: 1 } }, { id: 9, name: 'X', is_private: 1, owner_id: 1 }, 'sock');
expect(broadcast).not.toHaveBeenCalled();
});
it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', () => {
it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', async () => {
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 });
const broadcast = vi.fn();
const broadcastItem = vi.fn();
// Was public before → public→private transition.
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 0, owner_id: 1 });
const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock');
await new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:created', expect.anything(), expect.anything(), 'sock');
});
it('re-shares a freshly-public item to the whole room (#858)', () => {
it('re-shares a freshly-public item to the whole room (#858)', async () => {
const updated = { id: 9, name: 'X', is_private: 0, owner_id: 1 };
const updateItem = vi.fn().mockReturnValue(updated);
const broadcast = vi.fn();
// Was private before → private→public transition: create for those missing it, then update for all.
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 });
const svc = makeService({ updateItem, broadcast, getItemPrivacy } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock');
await new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: updated }, 'sock');
});
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', () => {
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', async () => {
const broadcast = vi.fn();
const svc = makeService({ updateItem: vi.fn().mockReturnValue({ invalidBag: true }), broadcast } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { bag_id: 999 }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { bag_id: 999 }))).toEqual({
status: 400, body: { error: 'Bag not found' },
});
expect(broadcast).not.toHaveBeenCalled();
});
it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', () => {
it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', async () => {
const updateItem = vi.fn().mockReturnValue({ conflict: true, server: { id: 9, name: 'Theirs' } });
const broadcast = vi.fn();
const svc = makeService({ updateItem, broadcast } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({
expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({
status: 409, body: { error: 'conflict', server: { id: 9, name: 'Theirs' } },
});
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'Mine' }), ['name'], '2026-01-01 00:00:00', user.id);
@@ -267,38 +267,38 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('PUT /reorder', () => {
it('reorders the items and reports success', () => {
it('reorders the items and reports success', async () => {
const reorderItems = vi.fn();
const svc = makeService({ reorderItems } as Partial<PackingService>);
expect(new PackingController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(await new PackingController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]);
});
});
describe('DELETE /:id (remove)', () => {
it('404 when the item is missing', () => {
it('404 when the item is missing', async () => {
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).remove(user, '5', '9'))).toEqual({
expect(await thrown(async () => await new PackingController(svc).remove(user, '5', '9'))).toEqual({
status: 404, body: { error: 'Item not found' },
});
});
it('deletes a Common item and broadcasts to the room', () => {
it('deletes a Common item and broadcasts to the room', async () => {
const deleted = { id: 9, is_private: 0, owner_id: 1 };
const deleteItem = vi.fn().mockReturnValue(deleted);
const broadcast = vi.fn();
const svc = makeService({ deleteItem, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
expect(await new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
});
it('scopes the delete of a Shared item to the owner + recipients (#858)', () => {
it('scopes the delete of a Shared item to the owner + recipients (#858)', async () => {
const deleted = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const deleteItem = vi.fn().mockReturnValue(deleted);
const broadcastToViewers = vi.fn();
const svc = makeService({ deleteItem, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).remove(user, '5', '9', 'sock');
await new PackingController(svc).remove(user, '5', '9', 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, [1, 2], 'sock');
});
});
@@ -308,56 +308,56 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// (packingSetSharingRequestSchema requires the enum) — direct method calls
// bypass parameter pipes, so that path is covered by the e2e suite and the
// schema spec in @trek/shared.
it('PUT /:id/sharing 404 missing, 403 non-owner, else drops + re-emits', () => {
expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } });
expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } });
it('PUT /:id/sharing 404 missing, 403 non-owner, else drops + re-emits', async () => {
expect(await thrown(async () => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } });
expect(await thrown(async () => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } });
const updated = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const setItemSharing = vi.fn().mockReturnValue(updated);
const broadcast = vi.fn();
const broadcastToViewers = vi.fn();
const svc = makeService({ setItemSharing, broadcast, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock');
await new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock');
expect(setItemSharing).toHaveBeenCalledWith('5', '9', user.id, 'shared', [2]);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, [1, 2], 'sock');
});
it('POST /:id/clone 404 missing, else creates a personal copy for the caller', () => {
expect(thrown(() => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } });
it('POST /:id/clone 404 missing, else creates a personal copy for the caller', async () => {
expect(await thrown(async () => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } });
const item = { id: 12, is_private: 1, owner_id: 1 };
const cloneItem = vi.fn().mockReturnValue(item);
const broadcastToViewers = vi.fn();
const svc = makeService({ cloneItem, broadcastToViewers } as Partial<PackingService>);
expect(new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item });
expect(await new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item });
expect(cloneItem).toHaveBeenCalledWith('5', '9', user.id);
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1], 'sock');
});
it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', () => {
expect(thrown(() => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } });
it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', async () => {
expect(await thrown(async () => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } });
const item = { id: 9, is_private: 0, contributors: [{ user_id: 1 }] };
const addContributor = vi.fn().mockReturnValue(item);
const broadcast = vi.fn();
const svc = makeService({ addContributor, broadcast } as Partial<PackingService>);
new PackingController(svc).addContributor(user, '5', '9', 'sock');
await new PackingController(svc).addContributor(user, '5', '9', 'sock');
expect(addContributor).toHaveBeenCalledWith('5', '9', user.id);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock');
});
it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', () => {
it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', async () => {
const item = { id: 9, is_private: 0, contributors: [] };
const removeContributor = vi.fn().mockReturnValue(item);
const broadcast = vi.fn();
const svc = makeService({ removeContributor, broadcast } as Partial<PackingService>);
new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock');
await new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock');
expect(removeContributor).toHaveBeenCalledWith('5', '9', 2);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock');
});
});
describe('bags', () => {
it('GET /bags lists bags for the trip, with the unassigned weight alongside (#2191)', () => {
it('GET /bags lists bags for the trip, with the unassigned weight alongside (#2191)', async () => {
// One service call, so the SUM…GROUP BY runs once per request — this route
// now fires on every item write for every connected client.
const listBagsWithWeights = vi.fn().mockReturnValue({
@@ -365,16 +365,16 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
unassigned_weight_grams: 150,
});
const svc = makeService({ listBagsWithWeights } as Partial<PackingService>);
expect(new PackingController(svc).listBags(user, '5')).toEqual({
expect(await new PackingController(svc).listBags(user, '5')).toEqual({
bags: [{ id: 3, name: 'Carry-on', total_weight_grams: 800 }],
unassigned_weight_grams: 150,
});
expect(listBagsWithWeights).toHaveBeenCalledTimes(1);
});
it('400 on bag create with blank name (bespoke check — the schema cannot see whitespace)', () => {
it('400 on bag create with blank name (bespoke check — the schema cannot see whitespace)', async () => {
const svc = makeService();
expect(thrown(() => new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({
status: 400, body: { error: 'Name is required' },
});
});
@@ -383,66 +383,66 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// (packingCreateBagRequestSchema requires a non-empty name) — direct method
// calls bypass parameter pipes, so that path is covered by the e2e suite.
it('creates a bag and broadcasts', () => {
it('creates a bag and broadcasts', async () => {
const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Carry-on' });
const broadcast = vi.fn();
const svc = makeService({ createBag, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({
expect(await new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({
bag: { id: 3, name: 'Carry-on' },
});
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-created', { bag: { id: 3, name: 'Carry-on' } }, 'sock');
});
it('forwards weight_limit_grams on bag create (#2154)', () => {
it('forwards weight_limit_grams on bag create (#2154)', async () => {
const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Backpack', weight_limit_grams: 8000 });
const svc = makeService({ createBag, broadcast: vi.fn() } as Partial<PackingService>);
new PackingController(svc).createBag(user, '5', { name: 'Backpack', weight_limit_grams: 8000 });
await new PackingController(svc).createBag(user, '5', { name: 'Backpack', weight_limit_grams: 8000 });
expect(createBag).toHaveBeenCalledWith('5', { name: 'Backpack', color: undefined, weight_limit_grams: 8000 });
});
it('404 on bag update when missing', () => {
it('404 on bag update when missing', async () => {
const svc = makeService({ updateBag: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Bag not found' },
});
});
it('updates a bag, forwards changed keys and broadcasts', () => {
it('updates a bag, forwards changed keys and broadcasts', async () => {
const updateBag = vi.fn().mockReturnValue({ id: 3, name: 'X' });
const broadcast = vi.fn();
const svc = makeService({ updateBag, broadcast } as Partial<PackingService>);
new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock');
await new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock');
expect(updateBag).toHaveBeenCalledWith('5', '3', expect.objectContaining({ name: 'X', color: '#000' }), ['name', 'color']);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-updated', { bag: { id: 3, name: 'X' } }, 'sock');
});
it('404 on bag delete when missing', () => {
it('404 on bag delete when missing', async () => {
const svc = makeService({ deleteBag: vi.fn().mockReturnValue(false) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({
expect(await thrown(async () => await new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({
status: 404, body: { error: 'Bag not found' },
});
});
it('deletes a bag and broadcasts', () => {
it('deletes a bag and broadcasts', async () => {
const deleteBag = vi.fn().mockReturnValue(true);
const broadcast = vi.fn();
const svc = makeService({ deleteBag, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true });
expect(await new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-deleted', { bagId: 3 }, 'sock');
});
it('404 on set-members when the bag is missing', () => {
it('404 on set-members when the bag is missing', async () => {
const svc = makeService({ setBagMembers: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }))).toEqual({
status: 404, body: { error: 'Bag not found' },
});
});
it('sets bag members and broadcasts', () => {
it('sets bag members and broadcasts', async () => {
const setBagMembers = vi.fn().mockReturnValue([{ user_id: 1 }, { user_id: 2 }]);
const broadcast = vi.fn();
const svc = makeService({ setBagMembers, broadcast } as Partial<PackingService>);
const res = new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }, 'sock');
const res = await new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }, 'sock');
expect(res).toEqual({ members: [{ user_id: 1 }, { user_id: 2 }] });
expect(setBagMembers).toHaveBeenCalledWith('5', '3', [1, 2]);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-members-updated', { bagId: 3, members: [{ user_id: 1 }, { user_id: 2 }] }, 'sock');
@@ -454,27 +454,27 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('templates', () => {
it('GET /templates returns the template list for an accessible trip', () => {
it('GET /templates returns the template list for an accessible trip', async () => {
const listTemplates = vi.fn().mockReturnValue([{ id: 1, name: 'Beach', item_count: 4 }]);
const svc = makeService({ listTemplates } as Partial<PackingService>);
expect(new PackingController(svc).listTemplates(user, '5')).toEqual({
expect(await new PackingController(svc).listTemplates(user, '5')).toEqual({
templates: [{ id: 1, name: 'Beach', item_count: 4 }],
});
});
it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => {
it('404 when applying a missing/empty template (POST stays 200 otherwise)', async () => {
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1', {}))).toEqual({
expect(await thrown(async () => await new PackingController(svc).applyTemplate(user, '5', 't1', {}))).toEqual({
status: 404, body: { error: 'Template not found or empty' },
});
});
it('applies a template, broadcasts the added items and reports the count', () => {
it('applies a template, broadcasts the added items and reports the count', async () => {
const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
const applyTemplate = vi.fn().mockReturnValue(items);
const broadcastItem = vi.fn();
const svc = makeService({ applyTemplate, broadcastItem } as Partial<PackingService>);
const res = new PackingController(svc).applyTemplate(user, '5', 't1', {}, 'sock');
const res = await new PackingController(svc).applyTemplate(user, '5', 't1', {}, 'sock');
expect(res).toEqual({ items, count: 3 });
expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'common', user.id);
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:template-applied', { items }, items[0], 'sock');
@@ -482,27 +482,27 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// #1565: the template must follow the tab the user is on, and a personal apply
// must not be broadcast to the rest of the trip.
it('applies a template into the personal list and keeps the broadcast to its owner', () => {
it('applies a template into the personal list and keeps the broadcast to its owner', async () => {
const items = [{ id: 1, is_private: 1, owner_id: user.id }];
const applyTemplate = vi.fn().mockReturnValue(items);
const broadcastItem = vi.fn();
const svc = makeService({ applyTemplate, broadcastItem } as Partial<PackingService>);
new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'personal' }, 'sock');
await new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'personal' }, 'sock');
expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'personal', user.id);
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:template-applied', { items }, items[0], 'sock');
});
it('falls back to the common pool for an unknown visibility', () => {
it('falls back to the common pool for an unknown visibility', async () => {
const applyTemplate = vi.fn().mockReturnValue([{ id: 1 }]);
const svc = makeService({ applyTemplate } as Partial<PackingService>);
new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'bogus' } as never);
await new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'bogus' } as never);
expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'common', user.id);
});
it('400 when an admin saves a template with no name (whitespace — the schema cannot see it)', () => {
it('400 when an admin saves a template with no name (whitespace — the schema cannot see it)', async () => {
const saveAsTemplate = vi.fn();
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', { name: ' ' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(admin, '5', { name: ' ' }))).toEqual({
status: 400, body: { error: 'Template name is required' },
});
expect(saveAsTemplate).not.toHaveBeenCalled();
@@ -513,26 +513,26 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// method calls bypass parameter pipes, so that path is covered by the e2e
// suite and the schema spec in @trek/shared.
it('403 when a non-admin tries to save a template', () => {
it('403 when a non-admin tries to save a template', async () => {
const saveAsTemplate = vi.fn();
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', { name: 'My template' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(user, '5', { name: 'My template' }))).toEqual({
status: 403, body: { error: 'Admin access required' },
});
expect(saveAsTemplate).not.toHaveBeenCalled();
});
it('400 when an admin saves a template with no items', () => {
it('400 when an admin saves a template with no items', async () => {
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' }))).toEqual({
status: 400, body: { error: 'No items to save' },
});
});
it('saves a template for an admin', () => {
it('saves a template for an admin', async () => {
const saveAsTemplate = vi.fn().mockReturnValue({ id: 7, name: 'My template' });
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' })).toEqual({
expect(await new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' })).toEqual({
template: { id: 7, name: 'My template' },
});
expect(saveAsTemplate).toHaveBeenCalledWith('5', admin.id, 'My template');
@@ -540,30 +540,30 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('category assignees', () => {
it('GET /category-assignees returns the assignee list for an accessible trip', () => {
it('GET /category-assignees returns the assignee list for an accessible trip', async () => {
const getCategoryAssignees = vi.fn().mockReturnValue([{ category: 'Clothes', user_id: 2 }]);
const svc = makeService({ getCategoryAssignees } as Partial<PackingService>);
expect(new PackingController(svc).categoryAssignees(user, '5')).toEqual({
expect(await new PackingController(svc).categoryAssignees(user, '5')).toEqual({
assignees: [{ category: 'Clothes', user_id: 2 }],
});
expect(getCategoryAssignees).toHaveBeenCalledWith('5');
});
it('decodes the URI-encoded category name before forwarding', () => {
it('decodes the URI-encoded category name before forwarding', async () => {
const updateCategoryAssignees = vi.fn().mockReturnValue([]);
const broadcast = vi.fn();
const notifyTagged = vi.fn();
const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial<PackingService>);
new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', { user_ids: [2] });
await new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', { user_ids: [2] });
expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'Toys & Games', [2]);
});
it('updates assignees, broadcasts and fires the tag notification', () => {
it('updates assignees, broadcasts and fires the tag notification', async () => {
const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]);
const broadcast = vi.fn();
const notifyTagged = vi.fn();
const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial<PackingService>);
const res = new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', { user_ids: [2] }, 'sock');
const res = await new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', { user_ids: [2] }, 'sock');
expect(res).toEqual({ assignees: [{ user_id: 2 }] });
expect(broadcast).toHaveBeenCalledWith('5', 'packing:assignees', { category: 'Clothes', assignees: [{ user_id: 2 }] }, 'sock');
expect(notifyTagged).toHaveBeenCalledWith('5', user, 'Clothes', [2]);
File diff suppressed because it is too large Load Diff
+53 -52
View File
@@ -73,6 +73,7 @@ beforeAll(async () => {
permissionsStub,
new QueryHelpersService(new DatabaseService(testDb)),
photoCacheStub,
await createTestUnitOfWork(testDb),
);
});
@@ -91,10 +92,10 @@ function shareRow(tripId: number | string) {
}
/** Owner + trip + a share link with all five flags on unless overridden. */
function seedSharedTrip(flags: Record<string, boolean> = {}) {
async function seedSharedTrip(flags: Record<string, boolean> = {}) {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { token } = svc.createOrUpdate(String(trip.id), user.id, {
const { token } = await svc.createOrUpdate(String(trip.id), user.id, {
share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true,
...flags,
});
@@ -104,15 +105,15 @@ function seedSharedTrip(flags: Record<string, boolean> = {}) {
// ── verifyTripAccess / canManage ──────────────────────────────────────────────
describe('verifyTripAccess and canManage', () => {
it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', () => {
it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
expect(svc.verifyTripAccess(String(trip.id), owner.id)?.id).toBe(trip.id);
expect(svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
expect((await svc.verifyTripAccess(String(trip.id), owner.id))?.id).toBe(trip.id);
expect(await svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
expect(await svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
});
it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', async () => {
@@ -130,11 +131,11 @@ describe('verifyTripAccess and canManage', () => {
// ── createOrUpdate ────────────────────────────────────────────────────────────
describe('createOrUpdate', () => {
it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', () => {
it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const before = Date.now();
const result = svc.createOrUpdate(String(trip.id), user.id, {});
const result = await svc.createOrUpdate(String(trip.id), user.id, {});
expect(result.created).toBe(true);
expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
const row = shareRow(trip.id);
@@ -148,10 +149,10 @@ describe('createOrUpdate', () => {
expect(expires).toBeLessThanOrEqual(Date.now() + ninetyDays + 5000);
});
it('SHARE-SVC-004: explicit flags override the defaults in both directions', () => {
it('SHARE-SVC-004: explicit flags override the defaults in both directions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {
await svc.createOrUpdate(String(trip.id), user.id, {
share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true,
});
const row = shareRow(trip.id);
@@ -159,24 +160,24 @@ describe('createOrUpdate', () => {
.toEqual([0, 0, 1, 1, 1]);
});
it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', () => {
it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = svc.createOrUpdate(String(trip.id), user.id, {});
const second = svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
const first = await svc.createOrUpdate(String(trip.id), user.id, {});
const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
expect(second).toEqual({ token: first.token, created: false });
expect(shareRow(trip.id).share_budget).toBe(1);
});
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', () => {
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
// Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it
// onto the 90-day clock.
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
const before = Date.now();
svc.createOrUpdate(String(trip.id), user.id, {});
await svc.createOrUpdate(String(trip.id), user.id, {});
const row = shareRow(trip.id);
// Omitted share_packing fell back to its default (off) — destructuring
// defaults apply on every call, not only on create.
@@ -191,17 +192,17 @@ describe('createOrUpdate', () => {
// ── get / remove ──────────────────────────────────────────────────────────────
describe('get and remove', () => {
it('SHARE-SVC-007: get returns null when no link exists', () => {
it('SHARE-SVC-007: get returns null when no link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(svc.get(String(trip.id))).toBeNull();
expect(await svc.get(String(trip.id))).toBeNull();
});
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', () => {
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { token } = svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
const info = svc.get(String(trip.id));
const { token } = await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
const info = await svc.get(String(trip.id));
expect(info).toEqual({
token,
created_at: expect.any(String),
@@ -213,13 +214,13 @@ describe('get and remove', () => {
});
});
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', () => {
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {});
svc.remove(String(trip.id));
await svc.createOrUpdate(String(trip.id), user.id, {});
await svc.remove(String(trip.id));
expect(shareRow(trip.id)).toBeUndefined();
expect(() => svc.remove(String(trip.id))).not.toThrow();
await expect(svc.remove(String(trip.id))).resolves.toBeUndefined();
});
});
@@ -231,7 +232,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
expect(await svc.getSharedTripData(token)).toBeNull();
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
@@ -239,7 +240,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-012: returns null when the trip row is gone', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
// The token normally cascades away with its trip; orphan it deliberately
// to pin the defensive `if (!trip) return null` branch.
testDb.exec('PRAGMA foreign_keys = OFF');
@@ -249,7 +250,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
@@ -275,7 +276,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-014: COALESCEs assignment times over place times', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id);
@@ -290,7 +291,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const p1 = createPlace(testDb, trip.id, { name: 'First' });
const p2 = createPlace(testDb, trip.id, { name: 'Second' });
@@ -305,7 +306,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id);
@@ -319,7 +320,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => {
const { user, trip, token } = seedSharedTrip();
const { user, trip, token } = await seedSharedTrip();
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id);
const packing = (await (await svc.getSharedTripData(token))!).packing;
@@ -327,19 +328,19 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => {
const { user, trip, token } = seedSharedTrip();
const { user, trip, token } = await seedSharedTrip();
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id);
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id);
const collab = (await (await svc.getSharedTripData(token))!).collab;
expect(collab).toHaveLength(1);
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
await svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
expect((await (await svc.getSharedTripData(token))!).collab).toEqual([]);
});
it('SHARE-SVC-019: withholds each section when its flag is off', async () => {
const { trip, token } = seedSharedTrip({
const { trip, token } = await seedSharedTrip({
share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false,
});
const day = createDay(testDb, trip.id);
@@ -361,7 +362,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => {
const { user, trip, token } = seedSharedTrip();
const { user, trip, token } = await seedSharedTrip();
expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('EUR');
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id);
expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('USD');
@@ -370,7 +371,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const proxied = createPlace(testDb, trip.id, { name: 'Proxied' });
const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' });
createPlace(testDb, trip.id, { name: 'Bare' });
@@ -385,7 +386,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user, token } = seedSharedTrip();
const { user, token } = await seedSharedTrip();
expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('instance-key');
@@ -394,7 +395,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state)
VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id);
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number)
@@ -408,7 +409,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`);
@@ -418,7 +419,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const stay = (): number => testDb.prepare(`
@@ -440,7 +441,7 @@ describe('getSharedTripData', () => {
describe('getSharedTripData redaction (#2320)', () => {
it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations
(trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review)
VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`)
@@ -459,7 +460,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const metadata = JSON.stringify({
airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK',
seat: '14A', price: 812, passenger_name: 'Ada Lovelace', confirmation_number: 'LOC123',
@@ -489,7 +490,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-034: a booking carries its ordered stops', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid;
const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng)
@@ -506,7 +507,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url);
@@ -524,7 +525,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue',
@@ -547,7 +548,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
@@ -560,7 +561,7 @@ describe('getSharedTripData redaction (#2320)', () => {
expect(JSON.stringify(data)).not.toContain('HOTEL-777');
});
it('SHARE-SVC-038: metadata that is not an object comes back as nothing', () => {
it('SHARE-SVC-038: metadata that is not an object comes back as nothing', async () => {
expect(publicReservationMetadata(null)).toBeNull();
expect(publicReservationMetadata('not json')).toBeNull();
expect(publicReservationMetadata('[1,2]')).toBeNull();
@@ -575,14 +576,14 @@ describe('getSharedTripData redaction (#2320)', () => {
describe('getSharedPlacePhotoKey', () => {
it('SHARE-SVC-022: returns null for an unknown or expired token', async () => {
expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull();
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled();
});
it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => {
const { trip, token } = seedSharedTrip({ share_map: false });
const { trip, token } = await seedSharedTrip({ share_map: false });
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id);
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
@@ -590,13 +591,13 @@ describe('getSharedPlacePhotoKey', () => {
});
it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', async () => {
const { token } = seedSharedTrip();
const { token } = await seedSharedTrip();
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled();
});
it('SHARE-SVC-025: resolves via serveKey when the encoded placeId matches the stored URL', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id);
// Wikimedia pseudo-IDs contain characters that must round-trip encoded.
const placeId = 'coords:48.8,2.3';
@@ -43,8 +43,8 @@ afterAll(() => testDb.close());
// A fresh path per call: getOrCreateLocal is keyed on file_path and resetTestDb
// leaves trek_photos alone, so a shared path would hand every test the same row.
let seq = 0;
function makePhoto(): number {
return repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
async function makePhoto(): Promise<number> {
return await repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
}
function read(id: number) {
@@ -54,41 +54,41 @@ function read(id: number) {
}
describe('TrekPhotosRepository.recordCaptureMetadata', () => {
it('TREKPHOTO-001: stores the capture time and coordinates', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
it('TREKPHOTO-001: stores the capture time and coordinates', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
// The album listing knows the date but not the place.
repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
await repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-003: fills only the half that was still missing', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
it('TREKPHOTO-003: fills only the half that was still missing', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
await repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
});
it('TREKPHOTO-005: an answer with nothing in it touches no row', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
repo.recordCaptureMetadata(id, {});
it('TREKPHOTO-005: an answer with nothing in it touches no row', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
await repo.recordCaptureMetadata(id, {});
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
@@ -116,7 +116,7 @@ beforeAll(async () => {
const buildReadModel = async (database: DatabaseService, roster: TripMembersService = membersSvc) =>
new TripReadModelService(
database, roster, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()),
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
placesSvc,
+1 -1
View File
@@ -152,7 +152,7 @@ beforeAll(async () => {
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
readModelSvc = new TripReadModelService(
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()),
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
placesSvc,
@@ -51,32 +51,39 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork } from '../../helpers/test-uow';
// The album-sync paths are the providers' half and have their own suites; these
// cases never reach them, so stubs keep the graph small.
const dbs = new DatabaseService(testDb);
const svc = new UnifiedMemoriesService(
dbs,
new TrekPhotosRepository(dbs),
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
notificationsStub(),
new AddonsService(dbs),
);
let svc: UnifiedMemoriesService;
// Legacy free-function names bound to the service, so the moved cases read as before.
const listTripPhotos = svc.listTripPhotos.bind(svc);
const listTripAlbumLinks = svc.listTripAlbumLinks.bind(svc);
const addTripPhotos = svc.addTripPhotos.bind(svc);
const setTripPhotoSharing = svc.setTripPhotoSharing.bind(svc);
const removeTripPhoto = svc.removeTripPhoto.bind(svc);
const createTripAlbumLink = svc.createTripAlbumLink.bind(svc);
const removeAlbumLink = svc.removeAlbumLink.bind(svc);
// Legacy free-function names forwarding to the service, so the moved cases read
// as before. Typed forwarders rather than `.bind` aliases: a bound alias is
// typed `any`, which hides a missing `await` from tsc and from all three lint
// rules now that these methods are async.
type Svc = UnifiedMemoriesService;
const listTripPhotos = (...a: Parameters<Svc['listTripPhotos']>) => svc.listTripPhotos(...a);
const listTripAlbumLinks = (...a: Parameters<Svc['listTripAlbumLinks']>) => svc.listTripAlbumLinks(...a);
const addTripPhotos = (...a: Parameters<Svc['addTripPhotos']>) => svc.addTripPhotos(...a);
const setTripPhotoSharing = (...a: Parameters<Svc['setTripPhotoSharing']>) => svc.setTripPhotoSharing(...a);
const removeTripPhoto = (...a: Parameters<Svc['removeTripPhoto']>) => svc.removeTripPhoto(...a);
const createTripAlbumLink = (...a: Parameters<Svc['createTripAlbumLink']>) => svc.createTripAlbumLink(...a);
const removeAlbumLink = (...a: Parameters<Svc['removeAlbumLink']>) => svc.removeAlbumLink(...a);
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new UnifiedMemoriesService(
dbs,
new TrekPhotosRepository(dbs),
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
notificationsStub(),
new AddonsService(dbs),
await createTestUnitOfWork(testDb),
);
});
beforeEach(() => {
@@ -200,7 +207,7 @@ describe('addTripPhotos', () => {
describe('setTripPhotoSharing', () => {
it('MEM-UNIFIED-008: returns 404 when user cannot access trip', async () => {
const result = await setTripPhotoSharing('9999', 1, 'immich', 'asset-1', true);
const result = await setTripPhotoSharing('9999', 1, 1, true);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
@@ -209,8 +216,8 @@ describe('setTripPhotoSharing', () => {
// ── removeTripPhoto ───────────────────────────────────────────────────────────
describe('removeTripPhoto', () => {
it('MEM-UNIFIED-009: returns 404 when user cannot access trip', () => {
const result = removeTripPhoto('9999', 1, 'immich', 'asset-1');
it('MEM-UNIFIED-009: returns 404 when user cannot access trip', async () => {
const result = await removeTripPhoto('9999', 1, 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
@@ -242,8 +249,8 @@ describe('createTripAlbumLink', () => {
// ── removeAlbumLink ───────────────────────────────────────────────────────────
describe('removeAlbumLink', () => {
it('MEM-UNIFIED-012: returns 404 when user cannot access trip', () => {
const result = removeAlbumLink('9999', '1', 1);
it('MEM-UNIFIED-012: returns 404 when user cannot access trip', async () => {
const result = await removeAlbumLink('9999', '1', 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
@@ -32,7 +32,7 @@ type Item = { id: number; name?: string; is_private?: number; owner_id?: number
*/
function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: Item | null } = {}) {
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType<typeof vi.fn> };
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub());
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub(), {} as never);
const data = {
listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]),
// The bodies here are the boring fixture; the item writes are typed as Item so a
@@ -68,7 +68,7 @@ const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/go
let packing: PackingService;
let places: PlacesService;
beforeAll(async () => {
packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub());
packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
places = new PlacesService(
dbs,
new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)),
@@ -145,25 +145,25 @@ describe('PlacesService.update — optimistic concurrency', () => {
});
describe('updateItem (packing) — optimistic concurrency', () => {
it('migration added updated_at and createItem stamps it', () => {
it('migration added updated_at and createItem stamps it', async () => {
const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[];
expect(cols.map(c => c.name)).toContain('updated_at');
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null };
const item = await packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null };
expect(item.updated_at).toBeTruthy();
});
it('returns a conflict when the packing token is stale', () => {
it('returns a conflict when the packing token is stale', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string };
const item = await packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string };
const stale = packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id);
const stale = await packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id);
expect(isUpdateConflict(stale)).toBe(true);
const fresh = packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id);
const fresh = await packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id);
expect(isUpdateConflict(fresh)).toBe(false);
expect((fresh as { name: string }).name).toBe('Edited');
});
@@ -54,22 +54,22 @@ describe('trek_photos media_type', () => {
expect(cols).toContain('duration_ms');
});
it('a local photo defaults to image', () => {
const id = getOrCreateLocalTrekPhoto('journey/a.jpg');
expect(resolveTrekPhoto(id)!.media_type).toBe('image');
it('a local photo defaults to image', async () => {
const id = await getOrCreateLocalTrekPhoto('journey/a.jpg');
expect((await resolveTrekPhoto(id))!.media_type).toBe('image');
});
it('a local video stores media_type=video + duration', () => {
const id = getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200);
const row = resolveTrekPhoto(id)!;
it('a local video stores media_type=video + duration', async () => {
const id = await getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200);
const row = (await resolveTrekPhoto(id))!;
expect(row.media_type).toBe('video');
expect(row.duration_ms).toBe(4200);
expect(row.thumbnail_path).toBe('journey/poster.jpg');
});
it('a provider photo can be registered as video', () => {
it('a provider photo can be registered as video', async () => {
const { user } = createUser(testDb);
const id = getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video');
expect(resolveTrekPhoto(id)!.media_type).toBe('video');
const id = await getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video');
expect((await resolveTrekPhoto(id))!.media_type).toBe('video');
});
});