Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/api/src/attachments/attachments.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
6 changes: 5 additions & 1 deletion apps/api/src/attachments/attachments.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
export class AttachmentsModule {}
29 changes: 28 additions & 1 deletion apps/api/src/attachments/attachments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down
13 changes: 7 additions & 6 deletions apps/api/src/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 30 additions & 4 deletions apps/api/src/comments/dto/comment-responses.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
}
}
36 changes: 34 additions & 2 deletions apps/app/src/components/comments/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<Card className="bg-foreground/5 rounded-lg">
<CardContent className="text-foreground flex items-start gap-3 p-4">
Expand Down Expand Up @@ -175,9 +204,12 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
{comment.attachments.map((att) => (
<div key={att.id} className="flex items-center gap-2 text-xs">
<span>📎</span>
<span className="text-blue-600 hover:underline cursor-pointer">
<button
onClick={() => handleAttachmentClick(att.id, att.name)}
className="text-blue-600 hover:underline cursor-pointer text-left"
>
{att.name}
</span>
</button>
</div>
))}
</div>
Expand Down
18 changes: 8 additions & 10 deletions apps/app/src/components/comments/Comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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
* <Comments entityId={taskId} entityType="task" />
*
*
* @example
* // Custom title and inline variant
* <Comments
* entityId={riskId}
* entityType="risk"
* <Comments
* entityId={riskId}
* entityType="risk"
* title="Risk Discussion"
* variant="inline"
* />
Expand Down Expand Up @@ -76,7 +76,7 @@ export const Comments = ({
const content = (
<div className="space-y-4">
<CommentForm entityId={entityId} entityType={entityType} />

{commentsLoading && (
<div className="space-y-3">
{/* Simple comment skeletons */}
Expand Down Expand Up @@ -114,9 +114,7 @@ export const Comments = ({
<CardTitle>{title}</CardTitle>
<CardDescription>{defaultDescription}</CardDescription>
</CardHeader>
<CardContent>
{content}
</CardContent>
<CardContent>{content}</CardContent>
</Card>
);
};
17 changes: 9 additions & 8 deletions apps/app/src/hooks/use-api.ts
Original file line number Diff line number Diff line change
@@ -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(
<T = unknown>(
Expand All @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -87,7 +88,7 @@ export function useApi() {
// SWR-based GET requests (recommended for data fetching)
useSWR: <T = unknown>(endpoint: string | null, options?: UseApiSWROptions<T>) => {
return useApiSWR<T>(endpoint, {
organizationId: activeOrg.data?.id,
organizationId: orgIdFromParams,
...options,
});
},
Expand Down
Loading