mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.' } }] };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user