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

This commit is contained in:
jubnl
2026-09-22 02:21:06 +02:00
parent 6b755314d8
commit 37992de7ab
48 changed files with 1250 additions and 1219 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ export class JournalRpc {
// gallery entry aimed at bytes that never arrived.
await this.storage.put('journey', filename, Readable.from(buf), { contentType: MIME_BY_EXT[ext] ?? 'image/jpeg' });
const photo = this.journey.addPhoto(entryId, userId, `journey/${filename}`, undefined, input.caption);
const photo = await this.journey.addPhoto(entryId, userId, `journey/${filename}`, undefined, input.caption);
if (!photo) {
// Nothing references the object now, and nothing ever would.
await this.storage.delete('journey', filename).catch(() => {});
@@ -1552,19 +1552,19 @@ export class JourneyDomainService {
* the entry route could not, which is why a video dropped on an entry came
* back as a 400 (issue #2341).
*/
addPhoto(
async addPhoto(
entryId: number,
userId: number,
filePath: string,
thumbnailPath?: string,
caption?: string,
media?: { mediaType?: string; durationMs?: number | null },
): JourneyPhoto | null {
): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = this.photos.getOrCreateLocal(
const trekPhotoId = await this.photos.getOrCreateLocal(
filePath,
thumbnailPath,
null,
@@ -1578,7 +1578,7 @@ export class JourneyDomainService {
return result;
}
addProviderPhoto(
async addProviderPhoto(
entryId: number,
userId: number,
provider: string,
@@ -1586,12 +1586,12 @@ export class JourneyDomainService {
caption?: string,
passphrase?: string,
mediaType: string = 'image',
): JourneyPhoto | null {
): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
// skip if this photo is already linked to this entry
const alreadyLinked = this.db
@@ -1629,11 +1629,11 @@ export class JourneyDomainService {
}
// Upload photos to the journey gallery only (no entry association).
uploadGalleryPhotos(
async uploadGalleryPhotos(
journeyId: number,
userId: number,
filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[],
): JourneyPhoto[] {
): Promise<JourneyPhoto[]> {
if (!this.canEdit(journeyId, userId)) return [];
const results: any[] = [];
const now = this.ts();
@@ -1643,7 +1643,7 @@ export class JourneyDomainService {
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const f of filePaths) {
const trekPhotoId = this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null);
const trekPhotoId = await this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null);
this.db.prepare(
`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
@@ -1659,7 +1659,7 @@ export class JourneyDomainService {
}
// Add a provider photo to the gallery only (no entry link).
addProviderPhotoToGallery(
async addProviderPhotoToGallery(
journeyId: number,
userId: number,
provider: string,
@@ -1667,9 +1667,9 @@ export class JourneyDomainService {
caption?: string,
passphrase?: string,
mediaType: string = 'image',
): any | null {
): Promise<any | null> {
if (!this.canEdit(journeyId, userId)) return null;
const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const galleryId = this.db.connection.transaction(() => this.ensureInGallery(journeyId, trekPhotoId, caption))();
return this.db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
}
@@ -1687,10 +1687,10 @@ export class JourneyDomainService {
}
// Hard-delete a gallery photo (removes from all entries and the gallery).
deleteGalleryPhoto(
async deleteGalleryPhoto(
journeyPhotoId: number,
userId: number,
): { photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null {
): Promise<{ photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null> {
const row = this.db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as
| { id: number; journey_id: number; photo_id: number }
| undefined;
@@ -1703,18 +1703,18 @@ export class JourneyDomainService {
// cascade on journey_entry_photos.journey_photo_id handles junction cleanup
this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
this.photos.deleteIfOrphan(row.photo_id);
await this.photos.deleteIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null };
}
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
async setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
// photoId = journey_photos.id (gallery row); look up the trek_photo_id
const jp = this.db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as
| { photo_id: number }
| undefined;
if (!jp) return;
this.photos.setProvider(jp.photo_id, provider, assetId, ownerId);
await this.photos.setProvider(jp.photo_id, provider, assetId, ownerId);
// also denorm on gallery row for fast reads
this.db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(
provider,
@@ -1752,10 +1752,10 @@ export class JourneyDomainService {
}
// deletePhoto: hard-delete (backwards compat name used by old route).
deletePhoto(
async deletePhoto(
photoId: number,
userId: number,
): { id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null {
): Promise<{ id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null> {
const row = this.db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as
| { id: number; journey_id: number; photo_id: number }
| undefined;
@@ -1767,7 +1767,7 @@ export class JourneyDomainService {
| undefined;
this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
this.photos.deleteIfOrphan(row.photo_id);
await this.photos.deleteIfOrphan(row.photo_id);
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null, journey_id: row.journey_id };
}
+19 -17
View File
@@ -251,7 +251,7 @@ export class JourneyController {
const refused: Express.Multer.File[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined);
const photo = await this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined);
if (!photo) {
// No row points at this file, so nothing would ever clean it up.
refused.push(file);
@@ -262,7 +262,7 @@ export class JourneyController {
try {
const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname);
if (immichId) {
this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id);
await this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id);
Object.assign(photo, { provider: 'immich', asset_id: immichId, owner_id: user.id });
}
} catch {
@@ -314,7 +314,7 @@ export class JourneyController {
}
await this.commitJourneyUploads(poster ? [video, poster] : [video]);
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photo = this.journey.addPhoto(
const photo = await this.journey.addPhoto(
Number(entryId),
user.id,
`journey/${video.filename}`,
@@ -332,22 +332,23 @@ export class JourneyController {
}
@Post('entries/:entryId/provider-photos')
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) {
async providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
body.asset_ids.forEach((id, i) => {
for (let i = 0; i < body.asset_ids.length; i++) {
const id = body.asset_ids[i];
const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image';
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
if (photo) added.push(photo);
});
}
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image');
const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image');
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
@@ -387,7 +388,7 @@ export class JourneyController {
@Delete('photos/:photoId')
async deletePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string) {
const photo = this.journey.deletePhoto(Number(photoId), user.id);
const photo = await this.journey.deletePhoto(Number(photoId), user.id);
if (!photo) {
throw new HttpException({ error: 'Photo not found' }, 404);
}
@@ -432,7 +433,7 @@ export class JourneyController {
}
await this.commitJourneyUploads(files);
const filePaths = files.map((f) => ({ path: `journey/${f.filename}` }));
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths);
const photos = await this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths);
if (!photos.length) {
await this.discardJourneyUploads(files);
throw new HttpException({ error: 'Not allowed' }, 403);
@@ -471,7 +472,7 @@ export class JourneyController {
}
await this.commitJourneyUploads(poster ? [video, poster] : [video]);
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, [{
const photos = await this.journey.uploadGalleryPhotos(Number(id), user.id, [{
path: `journey/${video.filename}`,
thumbnail: poster ? `journey/${poster.filename}` : undefined,
mediaType: 'video',
@@ -489,22 +490,23 @@ export class JourneyController {
}
@Post(':id/gallery/provider-photos')
galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) {
async galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
body.asset_ids.forEach((aid, i) => {
for (let i = 0; i < body.asset_ids.length; i++) {
const aid = body.asset_ids[i];
const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image';
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
if (photo) added.push(photo);
});
}
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image');
const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image');
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
@@ -515,7 +517,7 @@ export class JourneyController {
@Delete(':id/gallery/:journeyPhotoId')
@HttpCode(204)
async deleteGalleryPhoto(@CurrentUser() user: User, @Param('journeyPhotoId') journeyPhotoId: string): Promise<void> {
const photo = this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
const photo = await this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
if (!photo) {
throw new HttpException({ error: 'Photo not found or not allowed' }, 404);
}
+5 -4
View File
@@ -562,13 +562,14 @@ export class JourneyMcp {
}
const photos: unknown[] = [];
asset_ids.forEach((assetId, i) => {
for (let i = 0; i < asset_ids.length; i++) {
const assetId = asset_ids[i];
const mediaType = media_types?.[i] === 'video' ? 'video' : 'image';
const photo = entryId === undefined
? this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType)
: this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType);
? await this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType)
: await this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType);
if (photo) photos.push(photo);
});
}
// Detached, exactly as the REST routes schedule it: the provider is asked
// when and where each photo was taken, and without that answer an attached
@@ -42,7 +42,7 @@ export class ImmichMemoriesController {
return;
}
if (typeof auto_upload === 'boolean') {
this.memories.immichSetAutoUpload(user.id, auto_upload);
await this.memories.immichSetAutoUpload(user.id, auto_upload);
}
if (result.warning) {
res.json({ success: true, warning: result.warning });
@@ -102,7 +102,7 @@ export class ImmichMemoriesController {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
@@ -126,7 +126,7 @@ export class ImmichMemoriesController {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
@@ -145,7 +145,7 @@ export class ImmichMemoriesController {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
+15 -15
View File
@@ -40,7 +40,7 @@ export class ImmichService {
private readonly storage: StorageService,
) {}
getImmichCredentials(userId: number) {
async getImmichCredentials(userId: number) {
const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
if (!user?.immich_url || !user?.immich_api_key) return null;
const apiKey = decrypt_api_key(user.immich_api_key);
@@ -71,8 +71,8 @@ export class ImmichService {
// ── Connection Settings ────────────────────────────────────────────────────
getConnectionSettings(userId: number) {
const creds = this.getImmichCredentials(userId);
async getConnectionSettings(userId: number) {
const creds = await this.getImmichCredentials(userId);
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
return {
immich_url: creds?.immich_url || '',
@@ -81,7 +81,7 @@ export class ImmichService {
};
}
setImmichAutoUpload(userId: number, enabled: boolean): void {
async setImmichAutoUpload(userId: number, enabled: boolean): Promise<void> {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
@@ -169,7 +169,7 @@ export class ImmichService {
async getConnectionStatus(
userId: number
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
try {
const resp = await safeFetch(`${creds.immich_url}/api/users/me`, {
@@ -189,7 +189,7 @@ export class ImmichService {
async browseTimeline(
userId: number
): Promise<{ buckets?: any; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -271,7 +271,7 @@ export class ImmichService {
page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
// Once a day filter is in play the raw pages and the answered pages stop
@@ -347,7 +347,7 @@ export class ImmichService {
ownerUserId?: number
): Promise<{ data?: any; error?: string; status?: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
const creds = await this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
try {
@@ -390,7 +390,7 @@ export class ImmichService {
ownerUserId?: number
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
const creds = await this.getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
@@ -430,7 +430,7 @@ export class ImmichService {
opts?: { mediaType?: string | null; range?: string },
): Promise<void> {
const effectiveUserId = ownerUserId ?? userId;
const creds = this.getImmichCredentials(effectiveUserId);
const creds = await this.getImmichCredentials(effectiveUserId);
if (!creds) {
handleServiceResult(response, fail('Not found', 404));
return;
@@ -467,7 +467,7 @@ export class ImmichService {
async listAlbums(
userId: number
): Promise<{ albums?: any[]; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -591,7 +591,7 @@ export class ImmichService {
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -639,10 +639,10 @@ export class ImmichService {
linkId: string,
userId: number,
): Promise<{ selection: Selection; total: number } | { error: string; status: number }> {
const response = this.access.getAlbumIdFromLink(tripId, linkId, userId);
const response = await this.access.getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
@@ -664,7 +664,7 @@ export class ImmichService {
// ── Upload to Immich ──────────────────────────────────────────────────────
async uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
const creds = this.getImmichCredentials(userId);
const creds = await this.getImmichCredentials(userId);
if (!creds) return null;
// Journey uploads store the uploads-relative 'journey/<file>' path; only
@@ -15,7 +15,7 @@ import { fail, success, type ServiceResult } from './memories.helpers';
export class MemoriesAccessService {
constructor(private readonly db: DatabaseService) {}
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
async canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise<boolean> {
if (requestingUserId === ownerUserId) {
return true;
}
@@ -63,7 +63,7 @@ export class MemoriesAccessService {
// ── Unified photo access check (trek_photos based) ──────────────────────
canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
async canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): Promise<boolean> {
const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId);
if (!photo) return false;
@@ -107,7 +107,7 @@ export class MemoriesAccessService {
// ── Album link syncing ──────────────────────────────────────────────────
getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
async getAlbumIdFromLink(tripId: string, linkId: string, userId: number): Promise<ServiceResult<string>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
@@ -123,7 +123,7 @@ export class MemoriesAccessService {
}
}
getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
async getAlbumLinkForSync(tripId: string, linkId: string, userId: number): Promise<ServiceResult<{ albumId: string; passphrase?: string }>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
@@ -142,7 +142,7 @@ export class MemoriesAccessService {
}
}
updateSyncTimeForAlbumLink(linkId: string): void {
async updateSyncTimeForAlbumLink(linkId: string): Promise<void> {
this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId);
}
}
+3 -3
View File
@@ -25,7 +25,7 @@ export class MemoriesService {
) {}
// ── Access check (reused by both provider asset routes) ──────────────────
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise<boolean> {
return this.access.canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
}
@@ -71,8 +71,8 @@ export class MemoriesService {
return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
this.immich.setImmichAutoUpload(userId, enabled);
immichSetAutoUpload(userId: number, enabled: boolean): Promise<void> {
return this.immich.setImmichAutoUpload(userId, enabled);
}
immichGetConnectionStatus(userId: number) {
@@ -35,7 +35,7 @@ export class PhotoCaptureBackfillService {
async run(trekPhotoIds: number[], userId: number): Promise<void> {
for (const id of trekPhotoIds) {
try {
const photo = this.photos.resolve(id);
const photo = await this.photos.resolve(id);
// A row that already knows both has nothing to gain, and a provider call
// per photo is the expensive part of an album import.
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
@@ -44,14 +44,14 @@ export class PhotoCaptureBackfillService {
// getPhotoInfo would only hand back what the DB row already says.
if (photo.provider === 'local') {
const meta = await this.readLocalExif(photo.file_path);
if (meta) this.photos.recordCaptureMetadata(id, meta);
if (meta) await this.photos.recordCaptureMetadata(id, meta);
continue;
}
const info = await this.resolver.getPhotoInfo(userId, id);
if (!info.success) continue;
this.photos.recordCaptureMetadata(id, {
await this.photos.recordCaptureMetadata(id, {
takenAt: info.data.takenAt ?? null,
lat: info.data.lat ?? null,
lng: info.data.lng ?? null,
@@ -76,7 +76,7 @@ export class PhotoResolverService {
kind: 'thumbnail' | 'original',
range?: string,
): Promise<void> {
const photo = this.photos.resolve(photoId);
const photo = await this.photos.resolve(photoId);
if (!photo) {
res.status(404).json({ error: 'Photo not found' });
return;
@@ -92,7 +92,7 @@ export class PhotoResolverService {
const result = await this.thumbnails.ensureLocalThumbnail(photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height);
await this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height);
}
}
if (thumbRel) {
@@ -169,7 +169,7 @@ export class PhotoResolverService {
userId: number,
photoId: number,
): Promise<ServiceResult<AssetInfo>> {
const photo = this.photos.resolve(photoId);
const photo = await this.photos.resolve(photoId);
if (!photo) return fail('Photo not found', 404);
// Local rows answer from the row itself — nothing to ask.
@@ -148,7 +148,7 @@ export class SynologyMemoriesController {
@Res() res: Response,
): Promise<void> {
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos'))) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase));
@@ -176,7 +176,7 @@ export class SynologyMemoriesController {
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos'))) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase);
+17 -17
View File
@@ -134,7 +134,7 @@ export class SynologyService {
private readonly notifications: NotificationsService,
) {}
private _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
private async _readSynologyUser(userId: number, columns: string[]): Promise<ServiceResult<SynologyUserRecord>> {
try {
const row = this.db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
@@ -153,8 +153,8 @@ export class SynologyService {
}
}
private _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
private async _getSynologyCredentials(userId: number): Promise<ServiceResult<SynologyCredentials>> {
const user = await this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password);
@@ -251,7 +251,7 @@ export class SynologyService {
}
private async _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
const creds = this._getSynologyCredentials(userId);
const creds = await this._getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<T>;
}
@@ -266,7 +266,7 @@ export class SynologyService {
const result = await this._fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
this._clearSynologySID(userId);
await this._clearSynologySID(userId);
const retrySession = await this._getSynologySession(userId);
if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>;
@@ -305,11 +305,11 @@ export class SynologyService {
}
private _clearSynologySID(userId: number): void {
private async _clearSynologySID(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
private _clearSynologySession(userId: number): void {
private async _clearSynologySession(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
@@ -322,15 +322,15 @@ export class SynologyService {
}
private async _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cached = this._readSynologyUser(userId, ['synology_sid', 'synology_did']);
const cached = await this._readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
this._clearSynologySID(userId);
await this._clearSynologySID(userId);
}
const creds = this._getSynologyCredentials(userId);
const creds = await this._getSynologyCredentials(userId);
if (!creds.success) {
return creds as ServiceResult<string>;
}
@@ -354,7 +354,7 @@ export class SynologyService {
}
async getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
const creds = this._getSynologyCredentials(userId);
const creds = await this._getSynologyCredentials(userId);
if (!creds.success) return creds as ServiceResult<SynologySettings>;
const session = await this._getSynologySession(userId);
return success({
@@ -372,7 +372,7 @@ export class SynologyService {
return fail(ssrf.error, 400);
}
const result = this._readSynologyUser(userId, ['synology_password'])
const result = await this._readSynologyUser(userId, ['synology_password'])
if (!result.success) return result as ServiceResult<string>;
const existingEncryptedPassword = result.data?.synology_password || null;
@@ -383,12 +383,12 @@ export class SynologyService {
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = this._readSynologyUser(userId, ['synology_url', 'synology_username']);
const existing = await this._readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
this._clearSynologySession(userId);
await this._clearSynologySession(userId);
// Fire-and-forget like unifiedService's photos_shared send — without the
// .catch an in-app insert failure became an unhandled rejection.
// Injected, not imported from the old notifications bridge — that module built
@@ -537,7 +537,7 @@ export class SynologyService {
* immichService for why the trip-side write lives in the unified service now.
*/
async collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise<ServiceResult<{ selection: Selection; total: number }>> {
const response = this.access.getAlbumLinkForSync(tripId, linkId, userId);
const response = await this.access.getAlbumLinkForSync(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>;
const { albumId, passphrase } = response.data;
@@ -654,7 +654,7 @@ export class SynologyService {
const parsedId = this._splitPackedSynologyId(photoId);
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
const synology_credentials = this._getSynologyCredentials(targetUserId);
const synology_credentials = await this._getSynologyCredentials(targetUserId);
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
const sid = await this._getSynologySession(targetUserId);
@@ -704,7 +704,7 @@ export class SynologyService {
return;
}
const synology_credentials = this._getSynologyCredentials(targetUserId);
const synology_credentials = await this._getSynologyCredentials(targetUserId);
if (!synology_credentials.success) {
handleServiceResult(response, synology_credentials);
return;
@@ -17,6 +17,7 @@ import {
type SyncAlbumResult,
} from './memories.helpers';
import { NotificationsService } from '../notifications/notifications.service';
import { UnitOfWork } from '../database/unit-of-work';
/**
* The provider-agnostic trip photo surface: which photos a trip shows, which
@@ -34,6 +35,7 @@ export class UnifiedMemoriesService {
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
) {}
private async _providers(): Promise<Array<{id: string; enabled: boolean}>> {
@@ -139,7 +141,7 @@ export class UnifiedMemoriesService {
return providerResult as ServiceResult<boolean>;
}
try {
const photoId = this.photos.getOrCreate(provider, assetId, userId, passphrase);
const photoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase);
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
@@ -221,12 +223,12 @@ export class UnifiedMemoriesService {
}
}
removeTripPhoto(
async removeTripPhoto(
tripId: string,
userId: number,
photoId: number,
sid?: string,
): ServiceResult<true> {
): Promise<ServiceResult<true>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -240,7 +242,7 @@ export class UnifiedMemoriesService {
AND photo_id = ?
`).run(tripId, userId, photoId);
this.photos.deleteIfOrphan(photoId);
await this.photos.deleteIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
@@ -291,7 +293,7 @@ export class UnifiedMemoriesService {
}
}
removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
async removeAlbumLink(tripId: string, linkId: string, userId: number): Promise<ServiceResult<true>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -301,7 +303,7 @@ export class UnifiedMemoriesService {
const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
@@ -309,7 +311,7 @@ export class UnifiedMemoriesService {
});
for (const { photo_id } of linkedPhotos) {
this.photos.deleteIfOrphan(photo_id);
await this.photos.deleteIfOrphan(photo_id);
}
return success(true);
@@ -362,7 +364,7 @@ export class UnifiedMemoriesService {
const result = await this.addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId);
if ('error' in result) return { error: result.error.message, status: result.error.status };
this.access.updateSyncTimeForAlbumLink(linkId);
await this.access.updateSyncTimeForAlbumLink(linkId);
return { success: true, added: result.data.added, total: collected.total };
}
@@ -379,7 +381,7 @@ export class UnifiedMemoriesService {
const result = await this.addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
this.access.updateSyncTimeForAlbumLink(linkId);
await this.access.updateSyncTimeForAlbumLink(linkId);
return success({ added: result.data.added, total: collected.data.total });
}
@@ -72,7 +72,7 @@ export class UnifiedMemoriesController {
@Body() body: RemoveTripPhotoDto,
@Res() res: Response,
): Promise<void> {
const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
const result = await this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
@@ -108,13 +108,13 @@ export class UnifiedMemoriesController {
}
@Delete('trips/:tripId/album-links/:linkId')
removeAlbumLink(
async removeAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Res() res: Response,
): void {
const result = this.memories.removeAlbumLink(tripId, linkId, user.id);
): Promise<void> {
const result = await this.memories.removeAlbumLink(tripId, linkId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
@@ -39,19 +39,19 @@ export class AdminPackingTemplatesController {
) {}
@Get()
list() {
return { templates: this.packing.listPackingTemplates() };
async list() {
return { templates: await this.packing.listPackingTemplates() };
}
@Get(':id')
get(@Param('id') id: string) {
return ok(this.packing.getPackingTemplate(id));
async get(@Param('id') id: string) {
return ok(await this.packing.getPackingTemplate(id));
}
@Post()
@HttpCode(201)
async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
const result = ok(this.packing.createPackingTemplate(body.name, user.id));
const result = ok(await this.packing.createPackingTemplate(body.name, user.id));
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_create',
@@ -63,13 +63,13 @@ export class AdminPackingTemplatesController {
}
@Put(':id')
update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.updatePackingTemplate(id, body));
async update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updatePackingTemplate(id, body));
}
@Delete(':id')
async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.packing.deletePackingTemplate(id));
const result = ok(await this.packing.deletePackingTemplate(id));
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_delete',
@@ -82,35 +82,35 @@ export class AdminPackingTemplatesController {
@Post(':id/categories')
@HttpCode(201)
createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.createTemplateCategory(id, body.name));
async createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.createTemplateCategory(id, body.name));
}
@Put(':templateId/categories/:catId')
updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.updateTemplateCategory(templateId, catId, body));
async updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updateTemplateCategory(templateId, catId, body));
}
@Delete(':templateId/categories/:catId')
deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
ok(this.packing.deleteTemplateCategory(templateId, catId));
async deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
ok(await this.packing.deleteTemplateCategory(templateId, catId));
return { success: true };
}
@Post(':templateId/categories/:catId/items')
@HttpCode(201)
createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.createTemplateItem(templateId, catId, body.name));
async createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.createTemplateItem(templateId, catId, body.name));
}
@Put(':templateId/items/:itemId')
updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) {
return ok(this.packing.updateTemplateItem(templateId, itemId, body));
async updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updateTemplateItem(templateId, itemId, body));
}
@Delete(':templateId/items/:itemId')
deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) {
ok(this.packing.deleteTemplateItem(templateId, itemId));
async deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) {
ok(await this.packing.deleteTemplateItem(templateId, itemId));
return { success: true };
}
}
+42 -42
View File
@@ -64,14 +64,14 @@ export class PackingController {
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
// Pass the viewer so private items (#858) owned by other members are hidden.
return { items: this.packing.listItems(tripId, user.id) };
return { items: await this.packing.listItems(tripId, user.id) };
}
@RequirePermission('packing_edit')
@Post('import')
importItems(
async importItems(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingImportDto,
@@ -81,7 +81,7 @@ export class PackingController {
if (body.items.length === 0) {
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
}
const created = this.packing.bulkImport(tripId, body.items, user.id);
const created = await this.packing.bulkImport(tripId, body.items, user.id);
for (const item of created) {
this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId);
}
@@ -91,14 +91,14 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post()
create(
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingCreateItemDto,
@Headers('x-socket-id') socketId?: string,
) {
// checked arrives as boolean or legacy 0/1 — the service coerces by truthiness.
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
const item = await this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
// A bag referenced in the body must exist on this trip (#2154). The payload
// is at fault, so 400 — the 404 'Bag not found' stays with the path routes.
if (isInvalidBagRef(item)) {
@@ -111,19 +111,19 @@ export class PackingController {
@RequirePermission('packing_edit')
@Put('reorder')
reorder(
async reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingReorderDto,
@Headers('x-socket-id') _socketId?: string,
) {
this.packing.reorderItems(tripId, body.orderedIds);
await this.packing.reorderItems(tripId, body.orderedIds);
return { success: true };
}
@RequirePermission('packing_edit')
@Put(':id')
update(
async update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -133,12 +133,12 @@ export class PackingController {
) {
// Privacy state before the change, so a public↔private toggle (#858) can route
// the broadcast correctly instead of leaking a freshly-privatized item.
const before = this.packing.getItemPrivacy(tripId, id);
const before = await this.packing.getItemPrivacy(tripId, id);
const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body;
// bodyKeys carries which keys the request actually provided (the presence-
// sentinel protocol); the parsed body only ever holds known schema keys.
// checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds.
const updated = this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id);
const updated = await this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -162,13 +162,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Delete(':id')
remove(
async remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const deleted = this.packing.deleteItem(tripId, id, user.id);
const deleted = await this.packing.deleteItem(tripId, id, user.id);
if (!deleted) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -180,14 +180,14 @@ export class PackingController {
@RequirePermission('packing_edit')
@Put(':id/sharing')
setSharing(
async setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: PackingSetSharingDto,
@Headers('x-socket-id') socketId?: string,
) {
const updated = this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []);
const updated = await this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -204,13 +204,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post(':id/clone')
@HttpCode(201)
clone(
async clone(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const item = this.packing.cloneItem(tripId, id, user.id);
const item = await this.packing.cloneItem(tripId, id, user.id);
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -222,13 +222,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post(':id/contributors')
addContributor(
async addContributor(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const item = this.packing.addContributor(tripId, id, user.id);
const item = await this.packing.addContributor(tripId, id, user.id);
if (!item) {
throw new HttpException({ error: 'Item not found or not a shared list item' }, 404);
}
@@ -239,7 +239,7 @@ export class PackingController {
@RequirePermission('packing_edit')
@Delete(':id/contributors/:userId')
removeContributor(
async removeContributor(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -248,7 +248,7 @@ export class PackingController {
) {
// You can drop your own pledge; the owner can remove anyone's.
const target = Number.parseInt(userId);
const item = this.packing.removeContributor(tripId, id, target);
const item = await this.packing.removeContributor(tripId, id, target);
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
@@ -257,16 +257,16 @@ export class PackingController {
}
@Get('bags')
listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
async listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
// unassigned_weight_grams rides along so the "no bag" pile and the grand
// total follow the same rule as the bags themselves (#2191) — a screen
// mixing true totals with per-viewer ones would be worse than either.
return this.packing.listBagsWithWeights(tripId);
return await this.packing.listBagsWithWeights(tripId);
}
@RequirePermission('packing_edit')
@Post('bags')
createBag(
async createBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingCreateBagDto,
@@ -276,14 +276,14 @@ export class PackingController {
if (!body.name.trim()) {
throw new HttpException({ error: 'Name is required' }, 400);
}
const bag = this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams });
const bag = await this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams });
this.packing.broadcast(tripId, 'packing:bag-created', { bag }, socketId);
return { bag };
}
@RequirePermission('packing_edit')
@Put('bags/:bagId')
updateBag(
async updateBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@@ -293,7 +293,7 @@ export class PackingController {
const { name, color, weight_limit_grams, user_id } = body;
// bodyKeys carries which keys the request actually provided (the presence-
// sentinel protocol); the parsed body only ever holds known schema keys.
const updated = this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body));
const updated = await this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body));
if (!updated) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
@@ -303,13 +303,13 @@ export class PackingController {
@RequirePermission('packing_edit')
@Delete('bags/:bagId')
deleteBag(
async deleteBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.packing.deleteBag(tripId, bagId)) {
if (!(await this.packing.deleteBag(tripId, bagId))) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
this.packing.broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, socketId);
@@ -320,14 +320,14 @@ export class PackingController {
}
@Get('templates')
listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { templates: this.packing.listTemplates() };
async listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { templates: await this.packing.listTemplates() };
}
@RequirePermission('packing_edit')
@Post('apply-template/:templateId')
@HttpCode(200)
applyTemplate(
async applyTemplate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('templateId') templateId: string,
@@ -335,7 +335,7 @@ export class PackingController {
@Headers('x-socket-id') socketId?: string,
) {
const visibility = body?.visibility === 'personal' ? 'personal' : 'common';
const added = this.packing.applyTemplate(tripId, templateId, visibility, user.id);
const added = await this.packing.applyTemplate(tripId, templateId, visibility, user.id);
if (!added) {
throw new HttpException({ error: 'Template not found or empty' }, 404);
}
@@ -346,14 +346,14 @@ export class PackingController {
@RequirePermission('packing_edit')
@Put('bags/:bagId/members')
setBagMembers(
async setBagMembers(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@Body() body: PackingBagMembersDto,
@Headers('x-socket-id') socketId?: string,
) {
const members = this.packing.setBagMembers(tripId, bagId, body.user_ids);
const members = await this.packing.setBagMembers(tripId, bagId, body.user_ids);
if (!members) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
@@ -363,7 +363,7 @@ export class PackingController {
@RequirePermission('packing_edit')
@Post('save-as-template')
saveAsTemplate(
async saveAsTemplate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PackingSaveTemplateDto,
@@ -375,7 +375,7 @@ export class PackingController {
if (!body.name.trim()) {
throw new HttpException({ error: 'Template name is required' }, 400);
}
const template = this.packing.saveAsTemplate(tripId, user.id, body.name.trim());
const template = await this.packing.saveAsTemplate(tripId, user.id, body.name.trim());
if (!template) {
throw new HttpException({ error: 'No items to save' }, 400);
}
@@ -383,13 +383,13 @@ export class PackingController {
}
@Get('category-assignees')
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { assignees: this.packing.getCategoryAssignees(tripId) };
async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { assignees: await this.packing.getCategoryAssignees(tripId) };
}
@RequirePermission('packing_edit')
@Put('category-assignees/:categoryName')
updateCategoryAssignees(
async updateCategoryAssignees(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('categoryName') categoryName: string,
@@ -397,9 +397,9 @@ export class PackingController {
@Headers('x-socket-id') socketId?: string,
) {
const category = decodeURIComponent(categoryName);
const rows = this.packing.updateCategoryAssignees(tripId, category, body.user_ids);
const rows = await this.packing.updateCategoryAssignees(tripId, category, body.user_ids);
this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId);
this.packing.notifyTagged(tripId, user, category, body.user_ids);
await this.packing.notifyTagged(tripId, user, category, body.user_ids);
return { assignees: rows };
}
}
+41 -41
View File
@@ -80,9 +80,9 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.createItem(tripId, {
const item = await this.packing.createItem(tripId, {
name,
category: category || 'General',
bag_id,
@@ -117,9 +117,9 @@ export class PackingMcp {
})
async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
const item = await this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
if (!item) return errorResult('Packing item not found.');
// Scoped to the people who may see it, exactly as the REST route does
// (#858, #1976). A Common item answers null here and goes to the room.
@@ -140,9 +140,9 @@ export class PackingMcp {
})
async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId);
const deleted = await this.packing.deleteItem(tripId, itemId, ctx.userId);
if (!deleted) return errorResult('Packing item not found.');
// deleteItem hands back the row it removed, so the delete can be scoped to
// the same people the item was ever visible to (#1976).
@@ -175,7 +175,7 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
// The service reads presence from bodyKeys, so a field has to be named there
@@ -184,8 +184,8 @@ export class PackingMcp {
// Privacy state before the write, so a public↔private flip routes the
// broadcast the way the REST route does instead of leaking a freshly
// privatized item (or leaving a stale copy on everyone else's screen).
const wasPrivate = !!this.packing.getItemPrivacy(tripId, itemId)?.is_private;
const item = this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId);
const wasPrivate = !!(await this.packing.getItemPrivacy(tripId, itemId))?.is_private;
const item = await this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId);
if (!item) return errorResult('Packing item not found.');
// A referenced bag must exist on this trip (#2154), as on the REST route.
if (isInvalidBagRef(item)) return errorResult('Bag not found.');
@@ -231,9 +231,9 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
const item = await this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
if (!item) return errorResult('Packing item not found.');
if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.');
// The viewer set just changed: drop the item from the whole room, then hand
@@ -259,9 +259,9 @@ export class PackingMcp {
})
async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.reorderItems(tripId, orderedIds);
await this.packing.reorderItems(tripId, orderedIds);
this.guards.safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
}
@@ -277,8 +277,8 @@ export class PackingMcp {
access: { group: 'packing', mode: 'read' },
})
async listPackingBags({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const bags = this.packing.listBags(tripId);
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const bags = await this.packing.listBags(tripId);
return ok({ bags });
}
@@ -297,11 +297,11 @@ export class PackingMcp {
})
async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
// listBags and the schema always carry, so the client/AI consumer matches.
const bag = { ...(this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] };
const bag = { ...(await this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] };
this.guards.safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
}
@@ -326,7 +326,7 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {};
const bodyKeys: string[] = [];
@@ -336,10 +336,10 @@ export class PackingMcp {
// an explicit null clears it.
if (weight_limit_grams !== undefined) { fields.weight_limit_grams = weight_limit_grams; bodyKeys.push('weight_limit_grams'); }
if (user_id !== undefined) { fields.user_id = user_id; bodyKeys.push('user_id'); }
const updated = this.packing.updateBag(tripId, bagId, fields, bodyKeys);
const updated = await this.packing.updateBag(tripId, bagId, fields, bodyKeys);
if (!updated) return errorResult('Bag not found.');
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
const bag = this.packing.listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
const bag = (await this.packing.listBags(tripId)).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
this.guards.safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
}
@@ -357,9 +357,9 @@ export class PackingMcp {
})
async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.deleteBag(tripId, bagId);
await this.packing.deleteBag(tripId, bagId);
// { bagId } matches the REST route and the plugin host (the legacy
// registrar's { id } was the odd one out).
this.guards.safeBroadcast(tripId, 'packing:bag-deleted', { bagId });
@@ -383,9 +383,9 @@ export class PackingMcp {
})
async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const members = this.packing.setBagMembers(tripId, bagId, userIds);
const members = await this.packing.setBagMembers(tripId, bagId, userIds);
if (!members) return errorResult('Bag not found.');
this.guards.safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
return ok({ members });
@@ -402,8 +402,8 @@ export class PackingMcp {
access: { group: 'packing', mode: 'read' },
})
async getPackingCategoryAssignees({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const assignees = this.packing.getCategoryAssignees(tripId);
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const assignees = await this.packing.getCategoryAssignees(tripId);
return ok({ assignees });
}
@@ -421,9 +421,9 @@ export class PackingMcp {
})
async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
const assignees = await this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
this.guards.safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
return ok({ assignees });
}
@@ -441,9 +441,9 @@ export class PackingMcp {
})
async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const items = this.packing.applyTemplate(tripId, templateId);
const items = await this.packing.applyTemplate(tripId, templateId);
if (items === null) return errorResult('Template not found.');
this.guards.safeBroadcast(tripId, 'packing:template-applied', { items });
this.packing.broadcastBagTotals(String(tripId));
@@ -461,8 +461,8 @@ export class PackingMcp {
access: { group: 'packing', mode: 'read' },
})
async listPackingTemplates({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
return ok({ templates: this.packing.listTemplates() });
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
return ok({ templates: await this.packing.listTemplates() });
}
@Tool({
@@ -478,11 +478,11 @@ export class PackingMcp {
})
async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
const template = await this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
if (!template) return errorResult('Nothing to save — the packing list is empty.');
return ok({ template });
}
@@ -501,7 +501,7 @@ export class PackingMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Templates are global; the REST route restricts management to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const result = this.packing.deletePackingTemplate(String(templateId));
const result = await this.packing.deletePackingTemplate(String(templateId));
if ('error' in result) return errorResult(result.error);
return ok({ success: true, name: result.name });
}
@@ -529,9 +529,9 @@ export class PackingMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const created = this.packing.bulkImport(tripId, items, ctx.userId);
const created = await this.packing.bulkImport(tripId, items, ctx.userId);
for (const item of created) {
this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item));
}
@@ -551,7 +551,7 @@ export class PackingMcp {
})
async tripPackingResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.packing.verifyTripAccess(id, ctx.userId)) {
if (id === null || !(await this.packing.verifyTripAccess(id, ctx.userId))) {
return {
contents: [{
uri: uri.href,
@@ -561,7 +561,7 @@ export class PackingMcp {
};
}
// Hide other members' private items (#858) from the requesting user.
const items = this.packing.listItems(id, ctx.userId);
const items = await this.packing.listItems(id, ctx.userId);
return {
contents: [{
uri: uri.href,
@@ -581,7 +581,7 @@ export class PackingMcp {
})
async tripPackingBagsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.packing.verifyTripAccess(id, ctx.userId)) {
if (id === null || !(await this.packing.verifyTripAccess(id, ctx.userId))) {
return {
contents: [{
uri: uri.href,
@@ -590,7 +590,7 @@ export class PackingMcp {
}],
};
}
const bags = this.packing.listBags(id);
const bags = await this.packing.listBags(id);
return {
contents: [{
uri: uri.href,
+9 -9
View File
@@ -47,7 +47,7 @@ export class PackingRpc {
const parsed = packingCreateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
const item = await this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
// A referenced bag must exist on this trip (#2154), as on the REST route.
if (isInvalidBagRef(item)) throw new BadParams(`no packing bag ${parsed.data.bag_id} on trip ${tripId}`);
this.packing.emitToViewers(String(tripId), 'packing:created', { item }, item, undefined);
@@ -64,9 +64,9 @@ export class PackingRpc {
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
// Read the privacy BEFORE the write, so a public/private toggle routes correctly.
const before = this.packing.getItemPrivacy(tripId, itemId);
const before = await this.packing.getItemPrivacy(tripId, itemId);
const input = parsed.data as Record<string, unknown>;
const updated = this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actor);
const updated = await this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actor);
if (!updated) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
if (isUpdateConflict(updated)) throw new BadParams('packing item was modified concurrently');
// A referenced bag must exist on this trip (#2154), as on the REST route.
@@ -84,7 +84,7 @@ export class PackingRpc {
const itemId = num(params.itemId, 'itemId');
const actor = this.guards.requireActor(ctx, 'packing item');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const deleted = this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null;
const deleted = await this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null;
if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
this.packing.emitToViewers(String(tripId), 'packing:deleted', { itemId }, deleted, undefined);
this.packing.broadcastBagTotals(String(tripId));
@@ -95,7 +95,7 @@ export class PackingRpc {
async listBags(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown[]> {
// Note the permission: the envelope really does gate this READ on the write
// grant. The decorator makes the oddity visible instead of burying it.
return await this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]);
return await this.guards.tripRead(params, ctx, async () => (await this.packing.listBags(String(num(params.tripId, 'tripId')))) as unknown[]);
}
@PluginMethod('packing.createBag', { permission: 'db:write:packing' })
@@ -105,7 +105,7 @@ export class PackingRpc {
const input = asPayload(params.input);
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const bag = this.packing.createBag(String(tripId), {
const bag = await this.packing.createBag(String(tripId), {
name: input.name,
color: typeof input.color === 'string' ? input.color : undefined,
weight_limit_grams: typeof input.weight_limit_grams === 'number' ? input.weight_limit_grams : undefined,
@@ -121,7 +121,7 @@ export class PackingRpc {
const actor = this.guards.requireActor(ctx, 'packing bag');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const input = asPayload(params.input);
const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input));
const bag = await this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input));
if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'packing:bag-updated', { bag }, undefined);
return bag;
@@ -133,7 +133,7 @@ export class PackingRpc {
const bagId = num(params.bagId, 'bagId');
const actor = this.guards.requireActor(ctx, 'packing bag');
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
if (!this.packing.deleteBag(String(tripId), String(bagId))) {
if (!(await this.packing.deleteBag(String(tripId), String(bagId)))) {
throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
}
this.realtime.broadcast(tripId, 'packing:bag-deleted', { bagId }, undefined);
@@ -151,7 +151,7 @@ export class PackingRpc {
// userIds sits on the params object itself, not under `input`.
const raw = asPayload(params).userIds;
const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : [];
const members = this.packing.setBagMembers(String(tripId), String(bagId), userIds);
const members = await this.packing.setBagMembers(String(tripId), String(bagId), userIds);
if (!members) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'packing:bag-members-updated', { bagId, members }, undefined);
return members;
+82 -80
View File
@@ -7,6 +7,7 @@ import type { UpdateConflict } from '../common/conflictResult';
import type { User } from '../../types';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
import { UnitOfWork } from '../database/unit-of-work';
/** Privacy fields stamped on a packing item (#858). */
type PrivacyFields = { is_private?: number; owner_id?: number | null };
@@ -60,9 +61,10 @@ export class PackingService {
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
async verifyTripAccess(tripId: string | number, userId: number) {
return this.db.canAccessTrip(tripId, userId);
}
@@ -168,7 +170,7 @@ export class PackingService {
* items (#858 three-tier sharing). Batched so the list endpoint stays one round
* of queries regardless of item count.
*/
private enrichItems(items: any[]): any[] {
private async enrichItems(items: any[]): Promise<any[]> {
if (items.length === 0) return items;
const ids = items.map(i => i.id);
const placeholders = ids.map(() => '?').join(',');
@@ -206,7 +208,7 @@ export class PackingService {
}));
}
listItems(tripId: string | number, userId?: number) {
async listItems(tripId: string | number, userId?: number) {
// Three-tier visibility (#858): Common (is_private=0) is visible to everyone;
// Personal/Shared (is_private=1) only to the owner (bringer) and the recipients
// it was explicitly shared with. Without a userId the unfiltered list is
@@ -229,12 +231,12 @@ export class PackingService {
ORDER BY sort_order ASC, created_at ASC
`, tripId, userId, userId);
}
return this.enrichItems(rows);
return await this.enrichItems(rows);
}
/** Reads an item's current privacy fields (#858) before an update, so the
* controller can detect a public↔private transition and route the broadcast. */
getItemPrivacy(tripId: string | number, id: string | number): PrivacyFields | undefined {
async getItemPrivacy(tripId: string | number, id: string | number): Promise<PrivacyFields | undefined> {
return this.db.get<PrivacyFields>('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?', id, tripId);
}
@@ -244,18 +246,18 @@ export class PackingService {
return isPrivateFallback ? 1 : 0;
}
createItem(
async createItem(
tripId: string | number,
data: { name: string; category?: string; checked?: boolean; quantity?: number; weight_grams?: number | null; bag_id?: number | null; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] },
ownerId?: number,
) {
if (data.bag_id != null && !this.bagInTrip(tripId, data.bag_id)) return { invalidBag: true } as const;
if (data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) return { invalidBag: true } as const;
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?', tripId)!;
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1));
const isPrivate = this.visibilityToPrivate(data.visibility, data.is_private);
const itemId = this.db.transaction(() => {
const itemId = await this.uow.transactional(async () => {
const result = this.db.run(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, weight_grams, bag_id, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
tripId, data.name, data.checked ? 1 : 0, data.category || 'Other', sortOrder, qty, data.weight_grams ?? null, data.bag_id ?? null, isPrivate, ownerId ?? null
@@ -264,26 +266,26 @@ export class PackingService {
// "Shared with specific people" — record the recipients it covers.
if (data.visibility === 'shared' && Array.isArray(data.recipient_ids)) {
const ins = this.db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of data.recipient_ids) if (uid !== ownerId && roster.has(uid)) ins.run(id, uid);
}
return id;
});
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', itemId)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', itemId)]))[0];
}
updateItem(
async updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean },
bodyKeys: string[],
ifMatch?: string,
actingUserId?: number,
): unknown | UpdateConflict | null {
): Promise<unknown | UpdateConflict | null> {
// Was a trip-scoped lookup, which let any member with packing_edit write to
// another member's restricted item (and read it back off the response).
const item = this.getItemInTrip(tripId, id, actingUserId);
const item = await this.getItemInTrip(tripId, id, actingUserId);
if (!item) return null;
// Optimistic concurrency (#1135): reject a stale offline overwrite. Absent
@@ -294,7 +296,7 @@ export class PackingService {
// A non-null bag about to be bound must belong to this trip (#2154) — the
// FK alone let any member point an item at another trip's bag.
if (bodyKeys.includes('bag_id') && data.bag_id != null && !this.bagInTrip(tripId, data.bag_id)) {
if (bodyKeys.includes('bag_id') && data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) {
return { invalidBag: true } as const;
}
@@ -332,7 +334,7 @@ export class PackingService {
id
);
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
// ── Three-tier sharing (#858): recipients, contributors, clone ───────────────
@@ -359,7 +361,7 @@ export class PackingService {
* these routes cannot be used to probe for ids. A missing actor denies too,
* rather than falling through unfiltered.
*/
private getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) {
private async getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) {
if (actorId == null) return undefined;
return this.db.get<{ id: number; owner_id: number | null; is_private: number; name: string; category: string | null; quantity: number; weight_grams: number | null; bag_id: number | null; updated_at?: string | null }>(
`SELECT * FROM packing_items WHERE id = ? AND trip_id = ? AND ${PackingService.VISIBLE_TO_ACTOR}`,
@@ -371,53 +373,53 @@ export class PackingService {
* Re-set who a "shared with specific people" item covers, and its visibility tier.
* Only the owner (bringer) may change this; a non-owner caller is rejected with null.
*/
setItemSharing(
async setItemSharing(
tripId: string | number,
id: string | number,
actingUserId: number,
visibility: PackingVisibility,
recipientIds: number[],
) {
const item = this.getItemInTrip(tripId, id, actingUserId);
const item = await this.getItemInTrip(tripId, id, actingUserId);
if (!item) return null;
// The owner controls sharing; an unowned legacy item is claimed by the actor.
if (item.owner_id != null && item.owner_id !== actingUserId) return { forbidden: true as const };
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('UPDATE packing_items SET is_private = ?, owner_id = COALESCE(owner_id, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?',
this.visibilityToPrivate(visibility), actingUserId, id);
this.db.run('DELETE FROM packing_item_recipients WHERE item_id = ?', id);
if (visibility === 'shared') {
const ins = this.db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
const owner = item.owner_id ?? actingUserId;
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of recipientIds) if (uid !== owner && roster.has(uid)) ins.run(id, uid);
}
// Leaving the Common tier drops any co-contributors (they only apply to Common).
if (visibility !== 'common') this.db.run('DELETE FROM packing_item_contributors WHERE item_id = ?', id);
});
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
/** "I can bring that too" — adds the user as a co-contributor on a Common item. */
addContributor(tripId: string | number, id: string | number, userId: number) {
const item = this.getItemInTrip(tripId, id, userId);
async addContributor(tripId: string | number, id: string | number, userId: number) {
const item = await this.getItemInTrip(tripId, id, userId);
if (!item || item.is_private !== 0) return null; // co-contribution is a Common-list concept
if (item.owner_id === userId) return null; // the bringer is already covering it
this.db.run("INSERT OR IGNORE INTO packing_item_contributors (item_id, user_id, status) VALUES (?, ?, 'accepted')", id, userId);
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
removeContributor(tripId: string | number, id: string | number, userId: number) {
const item = this.getItemInTrip(tripId, id, userId);
async removeContributor(tripId: string | number, id: string | number, userId: number) {
const item = await this.getItemInTrip(tripId, id, userId);
if (!item) return null;
this.db.run('DELETE FROM packing_item_contributors WHERE item_id = ? AND user_id = ?', id, userId);
return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0];
return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0];
}
/** True when the bag exists AND belongs to the trip — the referenced-id rule
* the FK cannot enforce (it only checks existence). */
private bagInTrip(tripId: string | number, bagId: number): boolean {
private async bagInTrip(tripId: string | number, bagId: number): Promise<boolean> {
return !!this.db.get('SELECT id FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
}
@@ -426,7 +428,7 @@ export class PackingService {
* owns, or one they belong to. Inheriting someone else's bag would drop the copy into
* their luggage and inflate their weight (#207).
*/
private bagForCloner(tripId: string | number, bagId: number | null, userId: number): number | null {
private async bagForCloner(tripId: string | number, bagId: number | null, userId: number): Promise<number | null> {
if (bagId == null) return null;
const bag = this.db.get<{ user_id: number | null }>('SELECT user_id FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
if (!bag) return null;
@@ -441,25 +443,25 @@ export class PackingService {
* Weight comes along — it is a property of the thing, and re-entering it by hand for
* every traveller was the whole complaint in #207.
*/
cloneItem(tripId: string | number, id: string | number, userId: number) {
const item = this.getItemInTrip(tripId, id, userId);
async cloneItem(tripId: string | number, id: string | number, userId: number) {
const item = await this.getItemInTrip(tripId, id, userId);
if (!item) return null;
return this.createItem(tripId, {
return await this.createItem(tripId, {
name: item.name,
category: item.category || undefined,
quantity: item.quantity,
weight_grams: item.weight_grams,
bag_id: this.bagForCloner(tripId, item.bag_id, userId),
bag_id: await this.bagForCloner(tripId, item.bag_id, userId),
visibility: 'personal',
}, userId);
}
deleteItem(tripId: string | number, id: string | number, actingUserId?: number) {
async deleteItem(tripId: string | number, id: string | number, actingUserId?: number) {
// Return the deleted row (not just a boolean) so callers can target the
// delete broadcast at the owner when the item was private (#858).
// Scoped to what the actor may see: trip membership alone used to be enough
// to delete another member's restricted item.
const item = this.getItemInTrip(tripId, id, actingUserId);
const item = await this.getItemInTrip(tripId, id, actingUserId);
if (!item) return null;
this.db.run('DELETE FROM packing_items WHERE id = ?', id);
@@ -468,14 +470,14 @@ export class PackingService {
// ── Bulk Import ────────────────────────────────────────────────────────────
bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
async bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?', tripId)!;
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const stmt = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const created: any[] = [];
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const item of items) {
if (!item.name?.trim()) continue;
const checked = item.checked ? 1 : 0;
@@ -524,7 +526,7 @@ export class PackingService {
* Keyed by bag id, with the unassigned pile under `null` — the same shape the
* "no bag" row on every packing surface needs.
*/
private bagWeightTotals(tripId: string | number): Map<number | null, number> {
private async bagWeightTotals(tripId: string | number): Promise<Map<number | null, number>> {
const rows = this.db.all<{ bag_id: number | null; total: number | null }>(`
SELECT bag_id, SUM(COALESCE(weight_grams, 0) * COALESCE(quantity, 1)) AS total
FROM packing_items
@@ -535,8 +537,8 @@ export class PackingService {
}
/** The weight of everything in the trip that is in no bag (#2191). */
unassignedWeightGrams(tripId: string | number): number {
return this.bagWeightTotals(tripId).get(null) ?? 0;
async unassignedWeightGrams(tripId: string | number): Promise<number> {
return (await this.bagWeightTotals(tripId)).get(null) ?? 0;
}
/**
@@ -547,16 +549,16 @@ export class PackingService {
* SUM…GROUP BY twice per request is not a cost worth paying for a nicer
* method list.
*/
listBagsWithWeights(tripId: string | number): { bags: unknown[]; unassigned_weight_grams: number } {
const totals = this.bagWeightTotals(tripId);
return { bags: this.decorateBags(tripId, totals), unassigned_weight_grams: totals.get(null) ?? 0 };
async listBagsWithWeights(tripId: string | number): Promise<{ bags: unknown[]; unassigned_weight_grams: number }> {
const totals = await this.bagWeightTotals(tripId);
return { bags: await this.decorateBags(tripId, totals), unassigned_weight_grams: totals.get(null) ?? 0 };
}
listBags(tripId: string | number) {
return this.decorateBags(tripId, this.bagWeightTotals(tripId));
async listBags(tripId: string | number) {
return await this.decorateBags(tripId, await this.bagWeightTotals(tripId));
}
private decorateBags(tripId: string | number, totals: Map<number | null, number>) {
private async decorateBags(tripId: string | number, totals: Map<number | null, number>) {
const bags = this.db.all<any>('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id', tripId);
const members = this.db.all<{ bag_id: number; user_id: number; username: string; avatar: string | null }>(`
SELECT bm.bag_id, bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
@@ -583,18 +585,18 @@ export class PackingService {
* "assigned to a bag", which is why the category and recipient writes below
* grew up without it.
*/
private tripRosterIds(tripId: string | number): Set<number> {
private async tripRosterIds(tripId: string | number): Promise<Set<number>> {
return this.db.rosterUserIds(tripId);
}
setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
async setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
const bag = this.db.get('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
if (!bag) return null;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('DELETE FROM packing_bag_members WHERE bag_id = ?', bagId);
const ins = this.db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
// Only real trip members may be bag members — never write an arbitrary account id.
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of userIds) if (roster.has(uid)) ins.run(bagId, uid);
});
const rows = this.db.all<{ user_id: number; username: string; avatar: string | null }>(`
@@ -605,7 +607,7 @@ export class PackingService {
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) {
async createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) {
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?', tripId)!;
const result = this.db.run('INSERT INTO packing_bags (trip_id, name, color, sort_order, weight_limit_grams) VALUES (?, ?, ?, ?, ?)',
tripId, data.name.trim(), data.color || '#6366f1', (maxOrder.max ?? -1) + 1, data.weight_limit_grams ?? null
@@ -613,7 +615,7 @@ export class PackingService {
return this.db.get('SELECT * FROM packing_bags WHERE id = ?', result.lastInsertRowid);
}
updateBag(
async updateBag(
tripId: string | number,
bagId: string | number,
data: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null },
@@ -623,7 +625,7 @@ export class PackingService {
if (!bag) return null;
// A bag may only be assigned to a real trip member; an off-roster id becomes unassigned.
const assignUser = data.user_id != null && this.tripRosterIds(tripId).has(data.user_id) ? data.user_id : null;
const assignUser = data.user_id != null && (await this.tripRosterIds(tripId)).has(data.user_id) ? data.user_id : null;
// weight_limit_grams follows the bodyKeys presence protocol like user_id:
// an omitted key leaves the limit unchanged, an explicit null clears it.
this.db.run(`UPDATE packing_bags SET
@@ -643,7 +645,7 @@ export class PackingService {
return this.db.get('SELECT b.*, COALESCE(u.display_name, u.username) as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?', bagId);
}
deleteBag(tripId: string | number, bagId: string | number): boolean {
async deleteBag(tripId: string | number, bagId: string | number): Promise<boolean> {
const bag = this.db.get('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId);
if (!bag) return false;
@@ -658,7 +660,7 @@ export class PackingService {
* can pick a template to apply. Management (create/edit/delete) stays admin-only
* under /api/admin/packing-templates.
*/
listTemplates() {
async listTemplates() {
return this.db.all<{ id: number; name: string; item_count: number }>(`
SELECT pt.id, pt.name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count
@@ -669,7 +671,7 @@ export class PackingService {
// ── Apply Template ─────────────────────────────────────────────────────────
applyTemplate(
async applyTemplate(
tripId: string | number,
templateId: string | number,
visibility: 'common' | 'personal' = 'common',
@@ -692,7 +694,7 @@ export class PackingService {
const insert = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, is_private, owner_id, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const added: any[] = [];
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const ti of templateItems) {
const result = insert.run(tripId, ti.name, ti.category, sortOrder++, isPrivate, owner);
const item = this.db.get('SELECT * FROM packing_items WHERE id = ?', result.lastInsertRowid);
@@ -705,7 +707,7 @@ export class PackingService {
// ── Save as Template ──────────────────────────────────────────────────────
saveAsTemplate(tripId: string | number, userId: number, templateName: string) {
async saveAsTemplate(tripId: string | number, userId: number, templateName: string) {
// A template is a durable, shareable artifact, so it may only capture what is
// the actor's to publish: the Common list plus their own items. It used to
// take every row in the trip, restricted ones included.
@@ -718,7 +720,7 @@ export class PackingService {
const categories = [...new Set(items.map(i => i.category || 'Other'))];
const templateId = this.db.transaction(() => {
const templateId = await this.uow.transactional(async () => {
const result = this.db.run('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)', templateName, userId);
const id = result.lastInsertRowid;
@@ -743,7 +745,7 @@ export class PackingService {
// ── Category Assignees ─────────────────────────────────────────────────────
getCategoryAssignees(tripId: string | number) {
async getCategoryAssignees(tripId: string | number) {
const rows = this.db.all<{ category_name: string; user_id: number; username: string; avatar: string | null }>(`
SELECT pca.category_name, pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_category_assignees pca
@@ -761,14 +763,14 @@ export class PackingService {
return assignees;
}
updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
this.db.transaction(() => {
async updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
await this.uow.transactional(async () => {
this.db.run('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?', tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = this.db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
// Same rule as setBagMembers: only people on this trip may be assigned.
const roster = this.tripRosterIds(tripId);
const roster = await this.tripRosterIds(tripId);
for (const uid of userIds) if (roster.has(uid)) insert.run(tripId, categoryName, uid);
}
});
@@ -784,9 +786,9 @@ export class PackingService {
// ── Reorder ────────────────────────────────────────────────────────────────
reorderItems(tripId: string | number, orderedIds: number[]): void {
async reorderItems(tripId: string | number, orderedIds: number[]): Promise<void> {
const update = this.db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
this.db.transaction(() => {
await this.uow.transactional(async () => {
orderedIds.forEach((id, index) => {
update.run(index, id, tripId);
});
@@ -810,7 +812,7 @@ export class PackingService {
// packing_template_categories like the sibling category routes do.
/** An item looked up through its category, so :templateId actually scopes it. */
private scopedTemplateItem(templateId: string, itemId: string) {
private async scopedTemplateItem(templateId: string, itemId: string) {
return this.db.get(`
SELECT ti.* FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
@@ -818,7 +820,7 @@ export class PackingService {
`, itemId, templateId);
}
listPackingTemplates() {
async listPackingTemplates() {
return this.db.all(`
SELECT pt.*, u.username as created_by_name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
@@ -829,7 +831,7 @@ export class PackingService {
`);
}
getPackingTemplate(id: string) {
async getPackingTemplate(id: string) {
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', id);
if (!template) return { error: 'Template not found', status: 404 };
const categories = this.db.all('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id', id);
@@ -841,21 +843,21 @@ export class PackingService {
return { template, categories, items };
}
createPackingTemplate(name: string, createdBy: number) {
async createPackingTemplate(name: string, createdBy: number) {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
const result = this.db.run('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)', name.trim(), createdBy);
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', result.lastInsertRowid);
return { template };
}
updatePackingTemplate(id: string, data: { name?: string }) {
async updatePackingTemplate(id: string, data: { name?: string }) {
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', id);
if (!template) return { error: 'Template not found', status: 404 };
if (data.name?.trim()) this.db.run('UPDATE packing_templates SET name = ? WHERE id = ?', data.name.trim(), id);
return { template: this.db.get('SELECT * FROM packing_templates WHERE id = ?', id) };
}
deletePackingTemplate(id: string) {
async deletePackingTemplate(id: string) {
const template = this.db.get<{ name?: string }>('SELECT * FROM packing_templates WHERE id = ?', id);
if (!template) return { error: 'Template not found', status: 404 };
this.db.run('DELETE FROM packing_templates WHERE id = ?', id);
@@ -864,7 +866,7 @@ export class PackingService {
// Template categories
createTemplateCategory(templateId: string, name: string) {
async createTemplateCategory(templateId: string, name: string) {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', templateId);
if (!template) return { error: 'Template not found', status: 404 };
@@ -873,7 +875,7 @@ export class PackingService {
return { category: this.db.get('SELECT * FROM packing_template_categories WHERE id = ?', result.lastInsertRowid) };
}
updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
async updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (data.name?.trim())
@@ -881,7 +883,7 @@ export class PackingService {
return { category: this.db.get('SELECT * FROM packing_template_categories WHERE id = ?', catId) };
}
deleteTemplateCategory(templateId: string, catId: string) {
async deleteTemplateCategory(templateId: string, catId: string) {
const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
this.db.run('DELETE FROM packing_template_categories WHERE id = ?', catId);
@@ -890,7 +892,7 @@ export class PackingService {
// Template items
createTemplateItem(templateId: string, catId: string, name: string) {
async createTemplateItem(templateId: string, catId: string, name: string) {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
@@ -899,23 +901,23 @@ export class PackingService {
return { item: this.db.get('SELECT * FROM packing_template_items WHERE id = ?', result.lastInsertRowid) };
}
updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) {
const item = this.scopedTemplateItem(templateId, itemId);
async updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) {
const item = await this.scopedTemplateItem(templateId, itemId);
if (!item) return { error: 'Item not found', status: 404 };
if (data.name?.trim())
this.db.run('UPDATE packing_template_items SET name = ? WHERE id = ?', data.name.trim(), itemId);
return { item: this.db.get('SELECT * FROM packing_template_items WHERE id = ?', itemId) };
}
deleteTemplateItem(templateId: string, itemId: string) {
const item = this.scopedTemplateItem(templateId, itemId);
async deleteTemplateItem(templateId: string, itemId: string) {
const item = await this.scopedTemplateItem(templateId, itemId);
if (!item) return { error: 'Item not found', status: 404 };
this.db.run('DELETE FROM packing_template_items WHERE id = ?', itemId);
return {};
}
/** Fire-and-forget tag notification, mirroring the legacy dynamic import. */
notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void {
async notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): Promise<void> {
if (!Array.isArray(userIds) || userIds.length === 0) return;
// Injected, not a lazy import of the old notifications bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
+5 -5
View File
@@ -19,12 +19,12 @@ import { CurrentUser } from '../auth/current-user.decorator';
export class PhotosController {
constructor(private readonly photos: PhotosService) {}
private requireAccess(user: User, rawId: string): number {
private async requireAccess(user: User, rawId: string): Promise<number> {
const photoId = Number(rawId);
if (!Number.isFinite(photoId)) {
throw new HttpException({ error: 'Invalid photo ID' }, 400);
}
if (!this.photos.canAccess(user.id, photoId)) {
if (!(await this.photos.canAccess(user.id, photoId))) {
throw new HttpException({ error: 'Forbidden' }, 403);
}
return photoId;
@@ -32,19 +32,19 @@ export class PhotosController {
@Get(':id/thumbnail')
async thumbnail(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
const photoId = this.requireAccess(user, id);
const photoId = await this.requireAccess(user, id);
await this.photos.stream(res, user.id, photoId, 'thumbnail');
}
@Get(':id/original')
async original(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response, @Headers('range') range?: string): Promise<void> {
const photoId = this.requireAccess(user, id);
const photoId = await this.requireAccess(user, id);
await this.photos.stream(res, user.id, photoId, 'original', range);
}
@Get(':id/info')
async info(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
const photoId = this.requireAccess(user, id);
const photoId = await this.requireAccess(user, id);
const result = await this.photos.info(user.id, photoId);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
+1 -1
View File
@@ -15,7 +15,7 @@ export class PhotosService {
private readonly resolver: PhotoResolverService,
) {}
canAccess(userId: number, photoId: number): boolean {
canAccess(userId: number, photoId: number): Promise<boolean> {
return this.access.canAccessTrekPhoto(userId, photoId);
}
@@ -21,13 +21,13 @@ import type { TrekPhoto } from '../../types';
export class TrekPhotosRepository {
constructor(private readonly db: DatabaseService) {}
getOrCreate(
async getOrCreate(
provider: string,
assetId: string,
ownerId: number,
passphrase?: string,
mediaType: string = 'image',
): number {
): Promise<number> {
const existing = this.db.get<{ id: number }>(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?',
provider, assetId, ownerId,
@@ -46,14 +46,14 @@ export class TrekPhotosRepository {
return Number(res.lastInsertRowid);
}
getOrCreateLocal(
async getOrCreateLocal(
filePath: string,
thumbnailPath?: string | null,
width?: number | null,
height?: number | null,
mediaType: string = 'image',
durationMs?: number | null,
): number {
): Promise<number> {
const existing = this.db.get<{ id: number }>(
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?", filePath,
);
@@ -66,12 +66,12 @@ export class TrekPhotosRepository {
return Number(res.lastInsertRowid);
}
resolve(photoId: number): TrekPhoto | null {
async resolve(photoId: number): Promise<TrekPhoto | null> {
return this.db.get<TrekPhoto>('SELECT * FROM trek_photos WHERE id = ?', photoId) || null;
}
/** Retarget an existing row — used when a local photo is uploaded to Immich. */
setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): void {
async setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): Promise<void> {
this.db.run(
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?',
provider, assetId, ownerId, trekPhotoId,
@@ -82,7 +82,7 @@ export class TrekPhotosRepository {
* Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions
* that were already known — a re-generated thumbnail must not blank them.
*/
recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): void {
async recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): Promise<void> {
this.db.run(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?',
thumbnailPath, width, height, photoId,
@@ -100,10 +100,10 @@ export class TrekPhotosRepository {
*
* A photo with neither is the normal case, not a failure.
*/
recordCaptureMetadata(
async recordCaptureMetadata(
photoId: number,
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
): void {
): Promise<void> {
const { takenAt = null, lat = null, lng = null } = meta;
if (takenAt == null && lat == null && lng == null) return;
// Coordinates are stored as a pair or not at all — a lone latitude is not a
@@ -123,7 +123,7 @@ export class TrekPhotosRepository {
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
*/
deleteIfOrphan(photoId: number): void {
async deleteIfOrphan(photoId: number): Promise<void> {
const stillUsed = this.db.get(`
SELECT 1 FROM trip_photos WHERE photo_id = ?
UNION ALL
+4 -4
View File
@@ -25,7 +25,7 @@ export class TripShareController {
constructor(private readonly share: ShareService) {}
private async requireManage(tripId: string, user: User) {
const trip = this.share.verifyTripAccess(tripId, user.id);
const trip = await this.share.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
@@ -42,7 +42,7 @@ export class TripShareController {
@Res({ passthrough: true }) res: Response,
) {
await this.requireManage(tripId, user);
const result = this.share.createOrUpdate(tripId, user.id, {
const result = await this.share.createOrUpdate(tripId, user.id, {
share_map: body.share_map,
share_bookings: body.share_bookings,
share_packing: body.share_packing,
@@ -62,14 +62,14 @@ export class TripShareController {
// trip while signed in; it does not let them hand out a copy that works
// without an account and outlives their membership.
await this.requireManage(tripId, user);
const info = this.share.get(tripId);
const info = await this.share.get(tripId);
return info ? info : { token: null };
}
@Delete()
async remove(@CurrentUser() user: User, @Param('tripId') tripId: string) {
await this.requireManage(tripId, user);
this.share.remove(tripId);
await this.share.remove(tripId);
return { success: true };
}
}
+6 -6
View File
@@ -40,9 +40,9 @@ export class ShareMcp {
// requires share_manage on every verb including this one: the payload is the
// token itself, and a token is an anonymous copy of the trip. Leaving this
// one on membership alone would just move the same hole to MCP.
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
const link = this.share.get(String(tripId));
const link = await this.share.get(String(tripId));
return ok({ link });
}
@@ -67,11 +67,11 @@ export class ShareMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, {
const { token, created } = await this.share.createOrUpdate(String(tripId), ctx.userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
});
return ok({ token, created });
@@ -88,9 +88,9 @@ export class ShareMcp {
})
async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
this.share.remove(String(tripId));
await this.share.remove(String(tripId));
return ok({ success: true });
}
}
+9 -7
View File
@@ -7,6 +7,7 @@ import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility';
import { SettingsService } from '../settings/settings.service';
import { UnitOfWork } from '../database/unit-of-work';
import type { User } from '../../types';
type Trip = TripAccess;
@@ -155,9 +156,10 @@ export class ShareService {
private readonly permissions: PermissionsService,
private readonly queryHelpers: QueryHelpersService,
private readonly photoCache: PlacePhotoCacheService,
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string, userId: number) {
async verifyTripAccess(tripId: string, userId: number) {
return this.dbs.canAccessTrip(tripId, userId);
}
@@ -174,7 +176,7 @@ export class ShareService {
* before the expires_at migration keep NULL until touched and remain valid
* indefinitely; an explicit update moves them onto the TTL.
*/
createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): { token: string; created: boolean } {
async createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): Promise<{ token: string; created: boolean }> {
const {
share_map = true,
share_bookings = true,
@@ -184,7 +186,7 @@ export class ShareService {
} = permissions;
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
return this.dbs.transaction(() => {
return await this.uow.transactional(async () => {
const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId);
if (existing) {
this.dbs.run(
@@ -206,7 +208,7 @@ export class ShareService {
/**
* Returns share token info for a trip, or null if no share link exists.
*/
get(tripId: string): ShareTokenInfo | null {
async get(tripId: string): Promise<ShareTokenInfo | null> {
const row = this.dbs.get<any>('SELECT * FROM share_tokens WHERE trip_id = ?', tripId);
if (!row) return null;
return {
@@ -223,7 +225,7 @@ export class ShareService {
/**
* Deletes the share token for a trip.
*/
remove(tripId: string): void {
async remove(tripId: string): Promise<void> {
this.dbs.run('DELETE FROM share_tokens WHERE trip_id = ?', tripId);
}
@@ -243,7 +245,7 @@ export class ShareService {
* route from these, and a trip with forty bookings is not forty round trips
* to the database.
*/
private publicEndpointsByReservation(tripId: number): Map<number, Array<Record<string, unknown>>> {
private async publicEndpointsByReservation(tripId: number): Promise<Map<number, Array<Record<string, unknown>>>> {
const rows = this.dbs.all<{ reservation_id: number } & Record<string, unknown>>(
`SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, e.timezone, e.local_date, e.local_time
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
@@ -380,7 +382,7 @@ export class ShareService {
// an imported ticket keeps its seat and its record locator. A public
// link shows what the booking is, not what it would take to change it
// (#2320). The endpoints ride along, since the map draws from them.
const endpoints = this.publicEndpointsByReservation(tripId);
const endpoints = await this.publicEndpointsByReservation(tripId);
reservations = this.dbs.all<any>(
`SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r
WHERE r.trip_id = ? AND ${publicReservationSql('r')}
@@ -72,7 +72,7 @@ export class TripReadModelService {
// Thread the viewer so another member's private/personal packing items (#858)
// stay hidden — without it listItems returns the UNFILTERED list.
const packingItems = this.packing.listItems(tripId, viewerUserId);
const packingItems = await this.packing.listItems(tripId, viewerUserId);
const packing = {
items: packingItems,
total: packingItems.length,
@@ -106,7 +106,7 @@ export class TripReadModelService {
places: await this.places.list(String(tripId), {}),
// Scope to the requesting member so other members' private packing items
// (#858) never land in this viewer's offline cache.
packingItems: this.packing.listItems(tripId, viewerId),
packingItems: await this.packing.listItems(tripId, viewerId),
todoItems: await this.todo.listItems(tripId),
budgetItems: await this.budget.listBudgetItems(tripId),
reservations: this.reservations.list(tripId),
+2 -2
View File
@@ -77,11 +77,11 @@ export class TripPromptsMcp {
when: packingAddonOn,
})
async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) {
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
// Hide other members' private items (#858) from the requesting user.
const items = this.packing.listItems(tripId, ctx.userId);
const items = await this.packing.listItems(tripId, ctx.userId);
if (!items.length) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] };
}