diff --git a/apps/api/src/attachments/attachments.controller.ts b/apps/api/src/attachments/attachments.controller.ts new file mode 100644 index 000000000..acd1cc7b0 --- /dev/null +++ b/apps/api/src/attachments/attachments.controller.ts @@ -0,0 +1,66 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { AttachmentsService } from './attachments.service'; + +@ApiTags('Attachments') +@Controller({ path: 'attachments', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class AttachmentsController { + constructor(private readonly attachmentsService: AttachmentsService) {} + + @Get(':attachmentId/download') + @ApiOperation({ + summary: 'Get attachment download URL', + description: 'Generate a fresh signed URL for downloading any attachment', + }) + @ApiParam({ + name: 'attachmentId', + description: 'Unique attachment identifier', + example: 'att_abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Download URL generated successfully', + schema: { + type: 'object', + properties: { + downloadUrl: { + type: 'string', + description: 'Signed URL for downloading the file', + example: + 'https://bucket.s3.amazonaws.com/path/to/file.pdf?signature=...', + }, + expiresIn: { + type: 'number', + description: 'URL expiration time in seconds', + example: 900, + }, + }, + }, + }) + async getAttachmentDownloadUrl( + @OrganizationId() organizationId: string, + @Param('attachmentId') attachmentId: string, + ): Promise<{ downloadUrl: string; expiresIn: number }> { + return await this.attachmentsService.getAttachmentDownloadUrl( + organizationId, + attachmentId, + ); + } +} diff --git a/apps/api/src/attachments/attachments.module.ts b/apps/api/src/attachments/attachments.module.ts index 46434a9db..52999437e 100644 --- a/apps/api/src/attachments/attachments.module.ts +++ b/apps/api/src/attachments/attachments.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AttachmentsController } from './attachments.controller'; import { AttachmentsService } from './attachments.service'; @Module({ + imports: [AuthModule], // Import AuthModule for HybridAuthGuard dependencies + controllers: [AttachmentsController], providers: [AttachmentsService], exports: [AttachmentsService], }) -export class AttachmentsModule {} \ No newline at end of file +export class AttachmentsModule {} diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index 7cf993489..ee24d917b 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -110,7 +110,7 @@ export class AttachmentsService { } /** - * Get all attachments for an entity + * Get all attachments for an entity WITH signed URLs (for backward compatibility) */ async getAttachments( organizationId: string, @@ -145,6 +145,33 @@ export class AttachmentsService { return attachmentsWithUrls; } + /** + * Get attachment metadata WITHOUT signed URLs (for on-demand URL generation) + */ + async getAttachmentMetadata( + organizationId: string, + entityId: string, + entityType: AttachmentEntityType, + ): Promise<{ id: string; name: string; type: string; createdAt: Date }[]> { + const attachments = await db.attachment.findMany({ + where: { + organizationId, + entityId, + entityType, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + return attachments.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.type, + createdAt: attachment.createdAt, + })); + } + /** * Get download URL for an attachment */ diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index c2521fe5d..ab706f1dc 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -98,14 +98,15 @@ export class CommentsService { }, }); - // Get attachments for each comment + // Get attachment metadata for each comment (WITHOUT signed URLs for on-demand generation) const commentsWithAttachments = await Promise.all( comments.map(async (comment) => { - const attachments = await this.attachmentsService.getAttachments( - organizationId, - comment.id, - AttachmentEntityType.comment, - ); + const attachments = + await this.attachmentsService.getAttachmentMetadata( + organizationId, + comment.id, + AttachmentEntityType.comment, + ); return { id: comment.id, diff --git a/apps/api/src/comments/dto/comment-responses.dto.ts b/apps/api/src/comments/dto/comment-responses.dto.ts index 79c864e2e..c6de5ee15 100644 --- a/apps/api/src/comments/dto/comment-responses.dto.ts +++ b/apps/api/src/comments/dto/comment-responses.dto.ts @@ -38,6 +38,32 @@ export class AttachmentResponseDto { createdAt: Date; } +export class AttachmentMetadataDto { + @ApiProperty({ + description: 'Unique identifier for the attachment', + example: 'att_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Original filename', + example: 'document.pdf', + }) + name: string; + + @ApiProperty({ + description: 'File type/MIME type', + example: 'application/pdf', + }) + type: string; + + @ApiProperty({ + description: 'Upload timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; +} + export class AuthorResponseDto { @ApiProperty({ description: 'User ID', @@ -78,14 +104,14 @@ export class CommentResponseDto { author: AuthorResponseDto; @ApiProperty({ - description: 'Attachments associated with this comment', - type: [AttachmentResponseDto], + description: 'Attachment metadata (URLs generated on-demand)', + type: [AttachmentMetadataDto], }) - attachments: AttachmentResponseDto[]; + attachments: AttachmentMetadataDto[]; @ApiProperty({ description: 'Comment creation timestamp', example: '2024-01-15T10:30:00Z', }) createdAt: Date; -} \ No newline at end of file +} diff --git a/apps/app/src/components/comments/CommentItem.tsx b/apps/app/src/components/comments/CommentItem.tsx index 89ffe95a2..3a06c71b7 100644 --- a/apps/app/src/components/comments/CommentItem.tsx +++ b/apps/app/src/components/comments/CommentItem.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useApi } from '@/hooks/use-api'; import { useCommentActions } from '@/hooks/use-comments-api'; import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; import { Button } from '@comp/ui/button'; @@ -56,6 +57,7 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) { // Use API hooks instead of server actions const { updateComment, deleteComment } = useCommentActions(); + const { get: apiGet } = useApi(); const handleEditToggle = () => { if (!isEditing) { @@ -105,6 +107,33 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) { } }; + const handleAttachmentClick = async (attachmentId: string, fileName: string) => { + try { + // Generate fresh download URL on-demand using the useApi hook (with org context) + const response = await apiGet<{ downloadUrl: string; expiresIn: number }>( + `/v1/attachments/${attachmentId}/download`, + ); + + if (response.error || !response.data?.downloadUrl) { + console.error('API Error Details:', { + status: response.status, + error: response.error, + data: response.data, + }); + throw new Error(response.error || 'API response missing downloadUrl'); + } + + // Open the fresh URL in a new tab + window.open(response.data.downloadUrl, '_blank', 'noopener,noreferrer'); + } catch (error) { + console.error('Error downloading attachment:', error); + + // Since we no longer pre-generate URLs, show user error when API fails + console.error('No fallback available - URLs are only generated on-demand'); + toast.error(`Failed to download ${fileName}`); + } + }; + return ( @@ -175,9 +204,12 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) { {comment.attachments.map((att) => (
📎 - +
))} diff --git a/apps/app/src/components/comments/Comments.tsx b/apps/app/src/components/comments/Comments.tsx index 4c3bffed4..4bf6659f2 100644 --- a/apps/app/src/components/comments/Comments.tsx +++ b/apps/app/src/components/comments/Comments.tsx @@ -18,8 +18,8 @@ export type CommentWithAuthor = { id: string; name: string; type: string; - downloadUrl: string; createdAt: string; + // downloadUrl removed - now generated on-demand only }>; createdAt: string; }; @@ -38,16 +38,16 @@ interface CommentsProps { /** * Reusable Comments component that works with any entity type. * Automatically handles data fetching, real-time updates, loading states, and error handling. - * + * * @example * // Basic usage * - * + * * @example * // Custom title and inline variant - * @@ -76,7 +76,7 @@ export const Comments = ({ const content = (
- + {commentsLoading && (
{/* Simple comment skeletons */} @@ -114,9 +114,7 @@ export const Comments = ({ {title} {defaultDescription} - - {content} - + {content} ); }; diff --git a/apps/app/src/hooks/use-api.ts b/apps/app/src/hooks/use-api.ts index 882c7f294..8da929f8a 100644 --- a/apps/app/src/hooks/use-api.ts +++ b/apps/app/src/hooks/use-api.ts @@ -1,15 +1,16 @@ 'use client'; import { api } from '@/lib/api-client'; -import { useActiveOrganization } from '@/utils/auth-client'; +import { useParams } from 'next/navigation'; import { useCallback } from 'react'; import { useApiSWR, UseApiSWROptions } from './use-api-swr'; /** - * Hook that provides API client with automatic organization context + * Hook that provides API client with automatic organization context from URL params */ export function useApi() { - const activeOrg = useActiveOrganization(); + const params = useParams(); + const orgIdFromParams = params?.orgId as string; const apiCall = useCallback( ( @@ -27,11 +28,11 @@ export function useApi() { organizationId = (typeof bodyOrOrgId === 'string' ? bodyOrOrgId : undefined) || explicitOrgId || - activeOrg.data?.id; + orgIdFromParams; } else { // For POST/PUT: second param is body, third is organizationId body = bodyOrOrgId; - organizationId = explicitOrgId || activeOrg.data?.id; + organizationId = explicitOrgId || orgIdFromParams; } if (!organizationId) { @@ -52,12 +53,12 @@ export function useApi() { throw new Error(`Unsupported method: ${method}`); } }, - [activeOrg.data?.id], + [orgIdFromParams], ); return { // Organization context - organizationId: activeOrg.data?.id, + organizationId: orgIdFromParams, // Standard API methods (for mutations) get: useCallback( @@ -87,7 +88,7 @@ export function useApi() { // SWR-based GET requests (recommended for data fetching) useSWR: (endpoint: string | null, options?: UseApiSWROptions) => { return useApiSWR(endpoint, { - organizationId: activeOrg.data?.id, + organizationId: orgIdFromParams, ...options, }); },