feat(collab): add shared links, chat images, and plugin fullscreen

Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab.
This commit is contained in:
Godric Hood
2026-09-06 18:21:18 +01:00
committed by Maurice
parent 59a1756529
commit 9543fdc3f2
47 changed files with 743 additions and 76 deletions
+41
View File
@@ -4442,6 +4442,47 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
},
/**
* Which chat message an uploaded image belongs to.
*
* A column on `trip_files` rather than a link row, matching the two that
* predate it: a chat image is uploaded for exactly one message and dies with
* it, so the cascade is the whole relationship. Guarded through
* pragma_table_info like every other column add here, not through a caught
* "duplicate column name": that swallows the next error too.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('trip_files')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'message_id')) {
db.exec('ALTER TABLE trip_files ADD COLUMN message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE');
}
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)');
},
/**
* Links somebody shared with the trip.
*
* Its own table rather than a note with a URL in it: a link is pinned,
* ordered and opened, and none of that is what a note does. `user_id` is who
* shared it, so the list can say so and so a member leaving takes their rows
* with them.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
db.exec(`CREATE TABLE IF NOT EXISTS collab_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
url TEXT NOT NULL,
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)');
},
];
if (currentVersion < migrations.length) {
+4 -2
View File
@@ -53,7 +53,7 @@ export class AddonsService {
getCollabFeatures() {
const rows = this.db
.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
)
.all() as { key: string; value: string }[];
const map: Record<string, string> = {};
@@ -61,15 +61,17 @@ export class AddonsService {
return {
chat: map['collab_chat_enabled'] !== 'false',
notes: map['collab_notes_enabled'] !== 'false',
links: map['collab_links_enabled'] !== 'false',
polls: map['collab_polls_enabled'] !== 'false',
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
};
}
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = {
chat: 'collab_chat_enabled',
notes: 'collab_notes_enabled',
links: 'collab_links_enabled',
polls: 'collab_polls_enabled',
whatsnext: 'collab_whatsnext_enabled',
};
+92 -10
View File
@@ -11,10 +11,11 @@ import {
Put,
Query,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import type { Options } from 'multer';
import path from 'path';
import fs from 'fs';
@@ -26,6 +27,8 @@ import {
CollabNoteUpdateDto,
CollabPollCreateDto,
CollabPollVoteDto,
CollabLinkCreateDto,
CollabLinkUpdateDto,
CollabMessageCreateDto,
CollabReactionDto,
} from './collab.dto';
@@ -35,6 +38,16 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
export const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
const MAX_CHAT_IMAGES = 4;
const CHAT_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
export const collabChatImageFilter: Options['fileFilter'] = (_req, file, cb) => {
if (!CHAT_IMAGE_TYPES.has(file.mimetype)) {
const err: Error & { statusCode?: number } = new Error('Only JPEG, PNG, GIF, and WebP images are allowed');
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
// Consumed by collab.module.ts's MulterModule factory; the rest of the multer
// options (spool destination, filename, limits) come from the storage upload
// factory.
@@ -189,6 +202,45 @@ export class CollabController {
return { success: true };
}
// ── Shared links ────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('links')
listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { links: this.collab.listLinks(tripId) };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('links')
createLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string) {
const link = this.collab.createLink(tripId, user.id, { title: body.title, url: body.url, pinned: Boolean(body.pinned) });
this.collab.broadcast(tripId, 'collab:link:created', { link }, socketId);
return { link };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Put('links/:id')
updateLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string) {
const link = this.collab.updateLink(tripId, id, {
title: body.title,
url: body.url,
pinned: body.pinned === undefined ? undefined : Boolean(body.pinned),
});
if (!link) throw new HttpException({ error: 'Link not found' }, 404);
this.collab.broadcast(tripId, 'collab:link:updated', { link }, socketId);
return { link };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('links/:id')
deleteLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.collab.deleteLink(tripId, id)) throw new HttpException({ error: 'Link not found' }, 404);
this.collab.broadcast(tripId, 'collab:link:deleted', { linkId: Number(id) }, socketId);
return { success: true };
}
// ── Polls ───────────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('polls')
@@ -257,20 +309,50 @@ export class CollabController {
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('messages')
createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @Headers('x-socket-id') socketId?: string) {
// The pipe's min(1)/max(5000) replaced the bespoke length checks (and still
// rejects before the trip-access check, like the legacy pre-access check
// did); min(1) doesn't trim, so whitespace-only text keeps its bespoke 400.
if (!body.text.trim()) {
throw new HttpException({ error: 'Message text is required' }, 400);
@UseInterceptors(FilesInterceptor('images', MAX_CHAT_IMAGES, { fileFilter: collabChatImageFilter, limits: { files: MAX_CHAT_IMAGES, fileSize: 10 * 1024 * 1024 } }))
async createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @UploadedFiles() files: Express.Multer.File[] | undefined, @Headers('x-socket-id') socketId?: string) {
const uploaded = files || [];
const cleanupSpool = () => uploaded.forEach(file => { if (file.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } });
if (body.text && body.text.length > 5000) {
cleanupSpool();
throw new HttpException({ error: 'text must be 5000 characters or less' }, 400);
}
const result = this.collab.createMessage(tripId, user.id, body.text, body.reply_to);
const trip = this.requireTrip(tripId, user);
if (uploaded.length && !this.collab.canUploadFiles(trip, user)) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
const text = (body.text || '').trim();
if (!text && !uploaded.length) {
cleanupSpool();
throw new HttpException({ error: 'Message text or image is required' }, 400);
}
const committed: string[] = [];
try {
for (const file of uploaded) {
await this.storage.put('files', file.filename, { tmpPath: file.path });
committed.push(file.filename);
}
} catch (err) {
cleanupSpool();
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw err;
}
const replyTo = body.reply_to === undefined || body.reply_to === '' || body.reply_to === null ? null : Number(body.reply_to);
const result = this.collab.createMessage(
tripId,
user.id,
text,
Number.isFinite(replyTo as number) ? replyTo : null,
uploaded.map(file => ({ filename: file.filename, originalname: file.originalname, size: file.size, mimetype: file.mimetype })),
);
if (result.error === 'reply_not_found') {
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw new HttpException({ error: 'Reply target message not found' }, 400);
}
this.collab.broadcast(tripId, 'collab:message:created', { message: result.message }, socketId);
const t = body.text.trim();
this.collab.notifyCollab(tripId, user, t.length > 80 ? t.substring(0, 80) + '...' : t);
const preview = text || 'sent an image';
this.collab.notifyCollab(tripId, user, preview.length > 80 ? preview.substring(0, 80) + '...' : preview);
return { message: result.message };
}
+4
View File
@@ -4,6 +4,8 @@ import {
collabNoteUpdateRequestSchema,
collabPollCreateRequestSchema,
collabPollVoteRequestSchema,
collabLinkCreateRequestSchema,
collabLinkUpdateRequestSchema,
collabMessageCreateRequestSchema,
collabReactionRequestSchema,
} from '@trek/shared';
@@ -17,5 +19,7 @@ export class CollabNoteCreateDto extends createZodDto(collabNoteCreateRequestSch
export class CollabNoteUpdateDto extends createZodDto(collabNoteUpdateRequestSchema) {}
export class CollabPollCreateDto extends createZodDto(collabPollCreateRequestSchema) {}
export class CollabPollVoteDto extends createZodDto(collabPollVoteRequestSchema) {}
export class CollabLinkCreateDto extends createZodDto(collabLinkCreateRequestSchema) {}
export class CollabLinkUpdateDto extends createZodDto(collabLinkUpdateRequestSchema) {}
export class CollabMessageCreateDto extends createZodDto(collabMessageCreateRequestSchema) {}
export class CollabReactionDto extends createZodDto(collabReactionRequestSchema) {}
+80 -2
View File
@@ -429,12 +429,71 @@ export class CollabService {
return true;
}
/* ------------------------------------------------------------------ */
/* Shared links */
/* ------------------------------------------------------------------ */
listLinks(tripId: string | number) {
return this.db.all(
`SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id
WHERE l.trip_id = ? ORDER BY l.pinned DESC, l.created_at DESC`,
tripId,
);
}
createLink(tripId: string | number, userId: number, data: { title: string; url: string; pinned?: boolean }) {
const result = this.db.run(
'INSERT INTO collab_links (trip_id,user_id,title,url,pinned) VALUES (?,?,?,?,?)',
tripId, userId, data.title.trim(), data.url.trim(), data.pinned ? 1 : 0,
);
return this.db.get('SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?', result.lastInsertRowid);
}
updateLink(tripId: string | number, linkId: string | number, data: { title?: string; url?: string; pinned?: boolean | number }) {
const existing = this.db.get<any>('SELECT * FROM collab_links WHERE id = ? AND trip_id = ?', linkId, tripId);
if (!existing) return null;
this.db.run(
'UPDATE collab_links SET title = ?, url = ?, pinned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
data.title?.trim() || existing.title,
data.url?.trim() || existing.url,
data.pinned === undefined ? existing.pinned : (data.pinned ? 1 : 0),
linkId,
tripId,
);
return this.db.get('SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?', linkId);
}
deleteLink(tripId: string | number, linkId: string | number): boolean {
return this.db.run('DELETE FROM collab_links WHERE id = ? AND trip_id = ?', linkId, tripId).changes > 0;
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
private formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) {
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
const attachments = msg.id && msg.trip_id
? this.db.all<any>(
`SELECT id, trip_id, message_id, filename, original_name, file_size, mime_type
FROM trip_files WHERE message_id = ? AND trip_id = ? AND deleted_at IS NULL
ORDER BY id ASC`,
msg.id, msg.trip_id,
)
: [];
return {
...msg,
user_avatar: avatarUrl(msg),
avatar_url: avatarUrl(msg),
reactions: reactions || [],
attachments: attachments.map((a: any) => ({
id: a.id,
filename: a.filename,
original_name: a.original_name,
file_size: a.file_size,
mime_type: a.mime_type,
url: `/api/trips/${a.trip_id}/files/${a.id}/download`,
})),
};
}
countMessages(tripId: string | number): number {
@@ -486,7 +545,13 @@ export class CollabService {
return messages.map(m => this.formatMessage(m, this.groupReactions(reactionsByMsg[m.id] || [])));
}
createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType<CollabService['formatMessage']> } {
createMessage(
tripId: string | number,
userId: number,
text: string,
replyTo?: number | null,
files: Array<{ filename: string; originalname: string; size: number; mimetype: string }> = [],
): { error?: string; message?: ReturnType<CollabService['formatMessage']> } {
if (replyTo) {
// A soft-deleted message is gone as far as anyone replying is concerned:
// its row survives only so the placeholder can be drawn where it was.
@@ -500,6 +565,14 @@ export class CollabService {
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`, tripId, userId, text.trim(), replyTo || null);
for (const file of files) {
this.db.run(
`INSERT INTO trip_files (trip_id, message_id, filename, original_name, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
tripId, result.lastInsertRowid, file.filename, file.originalname, file.size, file.mimetype, userId,
);
}
const message = this.db.get<CollabMessage>(`
SELECT m.*, u.username, u.avatar,
CASE WHEN rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text,
@@ -519,6 +592,11 @@ export class CollabService {
if (!message) return { error: 'not_found' };
if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' };
const attachments = this.db.all<{ filename: string }>('SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
for (const file of attachments) {
void this.storage.delete('files', path.basename(file.filename)).catch(() => { /* best effort */ });
}
this.db.run('DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
this.db.run('UPDATE collab_messages SET deleted = 1 WHERE id = ?', messageId);
return { username: message.username };
}
+3 -1
View File
@@ -226,7 +226,9 @@ export class FilesService {
}
listFiles(tripId: string | number, showTrash: boolean) {
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
const where = showTrash
? 'f.trip_id = ? AND f.deleted_at IS NOT NULL AND f.message_id IS NULL'
: 'f.trip_id = ? AND f.deleted_at IS NULL AND f.message_id IS NULL';
const files = this.db.all<TripFile>(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`, tripId);
const fileIds = files.map(f => f.id);