mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Backoffice can now "Request changes" on a pending settings change request without rejecting it outright: a new ChangesRequested status keeps the row open so the customer's next edit appends into the same request instead of starting a fresh cycle, and the reviewer's note persists across that round instead of being cleared on resubmit. Version History and Review History (previously two separate, differently-shaped lists) are merged into one chronological timeline under a new History tab, including document changes shown as a real previous-vs-current diff (both files openable). Bug fixes surfaced while wiring this up: - Replacing a single-file document slot left the old file live alongside the new one instead of retiring it (customer settings + onboarding uploads). - The "previous" file in a document diff 404'd once superseded — the preview route now also matches soft-deleted records. - A document replace was recorded twice in the timeline (once at upload, once again at change-request approval).
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
|
import {
|
|
Controller,
|
|
ForbiddenException,
|
|
Get,
|
|
Param,
|
|
ParseUUIDPipe,
|
|
Query,
|
|
Res,
|
|
} from "@nestjs/common";
|
|
import {
|
|
ApiBearerAuth,
|
|
ApiOperation,
|
|
ApiQuery,
|
|
ApiTags,
|
|
} from "@nestjs/swagger";
|
|
import { Response } from "express";
|
|
|
|
import { FilesService } from "./files.service";
|
|
|
|
@ApiTags("files")
|
|
@ApiBearerAuth()
|
|
@Controller("files")
|
|
export class FilesController {
|
|
constructor(private readonly filesService: FilesService) {}
|
|
|
|
@Get(":fileId")
|
|
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
|
|
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
|
|
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
|
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
|
// TODO: enforce ownership-by-resource here next (scope the file to the
|
|
// caller's booking/company before streaming). Until that lands, any resource
|
|
// whose files are cross-tenant sensitive must opt OUT of this route and expose
|
|
// its own checked endpoint — see the support_message case below.
|
|
@ApiOperation({
|
|
summary: "Stream a file by ID",
|
|
description:
|
|
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
|
"No resource context (e.g. booking ID) required. Serves inline by default so " +
|
|
"the browser can preview it; pass ?download=1 to force a download. " +
|
|
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
|
|
})
|
|
@ApiQuery({
|
|
name: "download",
|
|
required: false,
|
|
description: "Set to 1/true to force a download instead of inline preview.",
|
|
})
|
|
async download(
|
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
|
@Query("download") download: string | undefined,
|
|
@Res() res: Response,
|
|
) {
|
|
// Includes soft-deleted records: a superseded document (replaced via a
|
|
// single-file document slot, or resolved as part of a license/PoA swap)
|
|
// is only reachable by UUID through the change-request/version-history
|
|
// diff, where reviewers need to open the "previous" file to compare it.
|
|
const record = await this.filesService.findByIdIncludingDeleted(fileId);
|
|
|
|
// Chat attachments are cross-tenant sensitive and this route has no
|
|
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
|
// another. SupportAttachmentController scopes the caller to the owning
|
|
// thread; refuse here rather than quietly serving the bytes.
|
|
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
|
|
throw new ForbiddenException(
|
|
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
|
|
);
|
|
}
|
|
|
|
const { stream } = await this.filesService.streamById(fileId, {
|
|
includeDeleted: true,
|
|
});
|
|
const forceDownload = download === "1" || download === "true";
|
|
const disposition = forceDownload ? "attachment" : "inline";
|
|
|
|
res.setHeader("Content-Type", record.mimeType);
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`${disposition}; filename="${record.name}"`,
|
|
);
|
|
// Allow the browser to cache the streamed bytes briefly for smoother
|
|
// in-page previews (re-opening the viewer shouldn't re-hit MinIO).
|
|
res.setHeader("Cache-Control", "private, max-age=300");
|
|
stream.pipe(res);
|
|
}
|
|
}
|