Skip to content

Commit dde6d48

Browse files
committed
feat: implement attachment download functionality and metadata retrieval
- Added AttachmentsController to handle attachment download requests, generating signed URLs on-demand. - Introduced getAttachmentMetadata method in AttachmentsService for retrieving attachment details without signed URLs. - Updated CommentsService to utilize the new getAttachmentMetadata method for fetching attachment data. - Modified CommentItem component to handle attachment downloads using the new API endpoint. - Enhanced AttachmentResponseDto to include metadata properties for better clarity in responses.
1 parent a3f0679 commit dde6d48

File tree

8 files changed

+187
-32
lines changed

8 files changed

+187
-32
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
2+
import {
3+
ApiHeader,
4+
ApiOperation,
5+
ApiParam,
6+
ApiResponse,
7+
ApiSecurity,
8+
ApiTags,
9+
} from '@nestjs/swagger';
10+
import { OrganizationId } from '../auth/auth-context.decorator';
11+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
12+
import { AttachmentsService } from './attachments.service';
13+
14+
@ApiTags('Attachments')
15+
@Controller({ path: 'attachments', version: '1' })
16+
@UseGuards(HybridAuthGuard)
17+
@ApiSecurity('apikey')
18+
@ApiHeader({
19+
name: 'X-Organization-Id',
20+
description:
21+
'Organization ID (required for session auth, optional for API key auth)',
22+
required: false,
23+
})
24+
export class AttachmentsController {
25+
constructor(private readonly attachmentsService: AttachmentsService) {}
26+
27+
@Get(':attachmentId/download')
28+
@ApiOperation({
29+
summary: 'Get attachment download URL',
30+
description: 'Generate a fresh signed URL for downloading any attachment',
31+
})
32+
@ApiParam({
33+
name: 'attachmentId',
34+
description: 'Unique attachment identifier',
35+
example: 'att_abc123def456',
36+
})
37+
@ApiResponse({
38+
status: 200,
39+
description: 'Download URL generated successfully',
40+
schema: {
41+
type: 'object',
42+
properties: {
43+
downloadUrl: {
44+
type: 'string',
45+
description: 'Signed URL for downloading the file',
46+
example:
47+
'https://bucket.s3.amazonaws.com/path/to/file.pdf?signature=...',
48+
},
49+
expiresIn: {
50+
type: 'number',
51+
description: 'URL expiration time in seconds',
52+
example: 900,
53+
},
54+
},
55+
},
56+
})
57+
async getAttachmentDownloadUrl(
58+
@OrganizationId() organizationId: string,
59+
@Param('attachmentId') attachmentId: string,
60+
): Promise<{ downloadUrl: string; expiresIn: number }> {
61+
return await this.attachmentsService.getAttachmentDownloadUrl(
62+
organizationId,
63+
attachmentId,
64+
);
65+
}
66+
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { Module } from '@nestjs/common';
2+
import { AuthModule } from '../auth/auth.module';
3+
import { AttachmentsController } from './attachments.controller';
24
import { AttachmentsService } from './attachments.service';
35

46
@Module({
7+
imports: [AuthModule], // Import AuthModule for HybridAuthGuard dependencies
8+
controllers: [AttachmentsController],
59
providers: [AttachmentsService],
610
exports: [AttachmentsService],
711
})
8-
export class AttachmentsModule {}
12+
export class AttachmentsModule {}

apps/api/src/attachments/attachments.service.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export class AttachmentsService {
110110
}
111111

112112
/**
113-
* Get all attachments for an entity
113+
* Get all attachments for an entity WITH signed URLs (for backward compatibility)
114114
*/
115115
async getAttachments(
116116
organizationId: string,
@@ -145,6 +145,33 @@ export class AttachmentsService {
145145
return attachmentsWithUrls;
146146
}
147147

148+
/**
149+
* Get attachment metadata WITHOUT signed URLs (for on-demand URL generation)
150+
*/
151+
async getAttachmentMetadata(
152+
organizationId: string,
153+
entityId: string,
154+
entityType: AttachmentEntityType,
155+
): Promise<{ id: string; name: string; type: string; createdAt: Date }[]> {
156+
const attachments = await db.attachment.findMany({
157+
where: {
158+
organizationId,
159+
entityId,
160+
entityType,
161+
},
162+
orderBy: {
163+
createdAt: 'asc',
164+
},
165+
});
166+
167+
return attachments.map((attachment) => ({
168+
id: attachment.id,
169+
name: attachment.name,
170+
type: attachment.type,
171+
createdAt: attachment.createdAt,
172+
}));
173+
}
174+
148175
/**
149176
* Get download URL for an attachment
150177
*/

apps/api/src/comments/comments.service.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,15 @@ export class CommentsService {
9898
},
9999
});
100100

101-
// Get attachments for each comment
101+
// Get attachment metadata for each comment (WITHOUT signed URLs for on-demand generation)
102102
const commentsWithAttachments = await Promise.all(
103103
comments.map(async (comment) => {
104-
const attachments = await this.attachmentsService.getAttachments(
105-
organizationId,
106-
comment.id,
107-
AttachmentEntityType.comment,
108-
);
104+
const attachments =
105+
await this.attachmentsService.getAttachmentMetadata(
106+
organizationId,
107+
comment.id,
108+
AttachmentEntityType.comment,
109+
);
109110

110111
return {
111112
id: comment.id,

apps/api/src/comments/dto/comment-responses.dto.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ export class AttachmentResponseDto {
3838
createdAt: Date;
3939
}
4040

41+
export class AttachmentMetadataDto {
42+
@ApiProperty({
43+
description: 'Unique identifier for the attachment',
44+
example: 'att_abc123def456',
45+
})
46+
id: string;
47+
48+
@ApiProperty({
49+
description: 'Original filename',
50+
example: 'document.pdf',
51+
})
52+
name: string;
53+
54+
@ApiProperty({
55+
description: 'File type/MIME type',
56+
example: 'application/pdf',
57+
})
58+
type: string;
59+
60+
@ApiProperty({
61+
description: 'Upload timestamp',
62+
example: '2024-01-15T10:30:00Z',
63+
})
64+
createdAt: Date;
65+
}
66+
4167
export class AuthorResponseDto {
4268
@ApiProperty({
4369
description: 'User ID',
@@ -78,14 +104,14 @@ export class CommentResponseDto {
78104
author: AuthorResponseDto;
79105

80106
@ApiProperty({
81-
description: 'Attachments associated with this comment',
82-
type: [AttachmentResponseDto],
107+
description: 'Attachment metadata (URLs generated on-demand)',
108+
type: [AttachmentMetadataDto],
83109
})
84-
attachments: AttachmentResponseDto[];
110+
attachments: AttachmentMetadataDto[];
85111

86112
@ApiProperty({
87113
description: 'Comment creation timestamp',
88114
example: '2024-01-15T10:30:00Z',
89115
})
90116
createdAt: Date;
91-
}
117+
}

apps/app/src/components/comments/CommentItem.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { useApi } from '@/hooks/use-api';
34
import { useCommentActions } from '@/hooks/use-comments-api';
45
import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar';
56
import { Button } from '@comp/ui/button';
@@ -56,6 +57,7 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
5657

5758
// Use API hooks instead of server actions
5859
const { updateComment, deleteComment } = useCommentActions();
60+
const { get: apiGet } = useApi();
5961

6062
const handleEditToggle = () => {
6163
if (!isEditing) {
@@ -105,6 +107,33 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
105107
}
106108
};
107109

110+
const handleAttachmentClick = async (attachmentId: string, fileName: string) => {
111+
try {
112+
// Generate fresh download URL on-demand using the useApi hook (with org context)
113+
const response = await apiGet<{ downloadUrl: string; expiresIn: number }>(
114+
`/v1/attachments/${attachmentId}/download`,
115+
);
116+
117+
if (response.error || !response.data?.downloadUrl) {
118+
console.error('API Error Details:', {
119+
status: response.status,
120+
error: response.error,
121+
data: response.data,
122+
});
123+
throw new Error(response.error || 'API response missing downloadUrl');
124+
}
125+
126+
// Open the fresh URL in a new tab
127+
window.open(response.data.downloadUrl, '_blank', 'noopener,noreferrer');
128+
} catch (error) {
129+
console.error('Error downloading attachment:', error);
130+
131+
// Since we no longer pre-generate URLs, show user error when API fails
132+
console.error('No fallback available - URLs are only generated on-demand');
133+
toast.error(`Failed to download ${fileName}`);
134+
}
135+
};
136+
108137
return (
109138
<Card className="bg-foreground/5 rounded-lg">
110139
<CardContent className="text-foreground flex items-start gap-3 p-4">
@@ -175,9 +204,12 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
175204
{comment.attachments.map((att) => (
176205
<div key={att.id} className="flex items-center gap-2 text-xs">
177206
<span>📎</span>
178-
<span className="text-blue-600 hover:underline cursor-pointer">
207+
<button
208+
onClick={() => handleAttachmentClick(att.id, att.name)}
209+
className="text-blue-600 hover:underline cursor-pointer text-left"
210+
>
179211
{att.name}
180-
</span>
212+
</button>
181213
</div>
182214
))}
183215
</div>

apps/app/src/components/comments/Comments.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export type CommentWithAuthor = {
1818
id: string;
1919
name: string;
2020
type: string;
21-
downloadUrl: string;
2221
createdAt: string;
22+
// downloadUrl removed - now generated on-demand only
2323
}>;
2424
createdAt: string;
2525
};
@@ -38,16 +38,16 @@ interface CommentsProps {
3838
/**
3939
* Reusable Comments component that works with any entity type.
4040
* Automatically handles data fetching, real-time updates, loading states, and error handling.
41-
*
41+
*
4242
* @example
4343
* // Basic usage
4444
* <Comments entityId={taskId} entityType="task" />
45-
*
45+
*
4646
* @example
4747
* // Custom title and inline variant
48-
* <Comments
49-
* entityId={riskId}
50-
* entityType="risk"
48+
* <Comments
49+
* entityId={riskId}
50+
* entityType="risk"
5151
* title="Risk Discussion"
5252
* variant="inline"
5353
* />
@@ -76,7 +76,7 @@ export const Comments = ({
7676
const content = (
7777
<div className="space-y-4">
7878
<CommentForm entityId={entityId} entityType={entityType} />
79-
79+
8080
{commentsLoading && (
8181
<div className="space-y-3">
8282
{/* Simple comment skeletons */}
@@ -114,9 +114,7 @@ export const Comments = ({
114114
<CardTitle>{title}</CardTitle>
115115
<CardDescription>{defaultDescription}</CardDescription>
116116
</CardHeader>
117-
<CardContent>
118-
{content}
119-
</CardContent>
117+
<CardContent>{content}</CardContent>
120118
</Card>
121119
);
122120
};

apps/app/src/hooks/use-api.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
'use client';
22

33
import { api } from '@/lib/api-client';
4-
import { useActiveOrganization } from '@/utils/auth-client';
4+
import { useParams } from 'next/navigation';
55
import { useCallback } from 'react';
66
import { useApiSWR, UseApiSWROptions } from './use-api-swr';
77

88
/**
9-
* Hook that provides API client with automatic organization context
9+
* Hook that provides API client with automatic organization context from URL params
1010
*/
1111
export function useApi() {
12-
const activeOrg = useActiveOrganization();
12+
const params = useParams();
13+
const orgIdFromParams = params?.orgId as string;
1314

1415
const apiCall = useCallback(
1516
<T = unknown>(
@@ -27,11 +28,11 @@ export function useApi() {
2728
organizationId =
2829
(typeof bodyOrOrgId === 'string' ? bodyOrOrgId : undefined) ||
2930
explicitOrgId ||
30-
activeOrg.data?.id;
31+
orgIdFromParams;
3132
} else {
3233
// For POST/PUT: second param is body, third is organizationId
3334
body = bodyOrOrgId;
34-
organizationId = explicitOrgId || activeOrg.data?.id;
35+
organizationId = explicitOrgId || orgIdFromParams;
3536
}
3637

3738
if (!organizationId) {
@@ -52,12 +53,12 @@ export function useApi() {
5253
throw new Error(`Unsupported method: ${method}`);
5354
}
5455
},
55-
[activeOrg.data?.id],
56+
[orgIdFromParams],
5657
);
5758

5859
return {
5960
// Organization context
60-
organizationId: activeOrg.data?.id,
61+
organizationId: orgIdFromParams,
6162

6263
// Standard API methods (for mutations)
6364
get: useCallback(
@@ -87,7 +88,7 @@ export function useApi() {
8788
// SWR-based GET requests (recommended for data fetching)
8889
useSWR: <T = unknown>(endpoint: string | null, options?: UseApiSWROptions<T>) => {
8990
return useApiSWR<T>(endpoint, {
90-
organizationId: activeOrg.data?.id,
91+
organizationId: orgIdFromParams,
9192
...options,
9293
});
9394
},

0 commit comments

Comments
 (0)