Skip to content

Commit 3d7ee5f

Browse files
Dhanus3133Marfuen
andauthored
feat(portal): display policies with PDF format (#1559)
Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 227a0fc commit 3d7ee5f

File tree

7 files changed

+158
-37
lines changed

7 files changed

+158
-37
lines changed

apps/app/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ REVALIDATION_SECRET="" # openssl rand -base64 32
77
NEXT_PUBLIC_PORTAL_URL="http://localhost:3002" # The employee portal uses port 3002 by default
88

99
# Recommended
10-
# Store attachemnts in any S3 compatible bucket, we use AWS
10+
# Store attachments in any S3 compatible bucket, we use AWS
1111
APP_AWS_ACCESS_KEY_ID="" # AWS Access Key ID
1212
APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key
1313
APP_AWS_REGION="" # AWS Region

apps/portal/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,10 @@ RESEND_DOMAIN="" # ex. mail.trycomp.ai
88
DATABASE_URL="" # Format: postgresql://user:password@host:port/database
99

1010
# Public
11-
NEXT_PUBLIC_BETTER_AUTH_URL="" # http://localhost:30001
11+
NEXT_PUBLIC_BETTER_AUTH_URL="" # http://localhost:30001
12+
13+
# AWS
14+
APP_AWS_ACCESS_KEY_ID="" # AWS Access Key ID
15+
APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key
16+
APP_AWS_REGION="" # AWS Region
17+
APP_AWS_BUCKET_NAME="" # AWS Bucket Name

apps/portal/src/app/(app)/(home)/[orgId]/components/policy/PolicyCard.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { JSONContent } from '@tiptap/react';
1414
import { ArrowRight, Check } from 'lucide-react';
1515
import { useState } from 'react';
1616
import { PolicyEditor } from './PolicyEditor';
17+
import { PortalPdfViewer } from './PortalPdfViewer';
1718

1819
interface PolicyCardProps {
1920
policy: Policy;
@@ -24,21 +25,16 @@ interface PolicyCardProps {
2425
isLastPolicy?: boolean;
2526
}
2627

27-
export function PolicyCard({
28-
policy,
29-
onNext,
30-
onComplete,
31-
onClick,
32-
member,
33-
isLastPolicy,
34-
}: PolicyCardProps) {
28+
export function PolicyCard({ policy, onNext, onComplete, member, isLastPolicy }: PolicyCardProps) {
3529
const [isAccepted, setIsAccepted] = useState(policy.signedBy.includes(member.id));
3630

3731
const handleAccept = () => {
3832
setIsAccepted(true);
3933
onComplete?.();
4034
};
4135

36+
const isPdfPolicy = policy.displayFormat === 'PDF';
37+
4238
return (
4339
<Card className="relative flex max-h-[calc(100vh-450px)] w-full flex-col shadow-md">
4440
{isAccepted && (
@@ -68,7 +64,11 @@ export function PolicyCard({
6864
<CardContent className="w-full flex-1 overflow-y-auto">
6965
<div className="w-full border-t pt-6">
7066
<div className="max-w-none">
71-
<PolicyEditor content={policy.content as JSONContent[]} />
67+
{isPdfPolicy ? (
68+
<PortalPdfViewer policyId={policy.id} s3Key={policy.pdfUrl} />
69+
) : (
70+
<PolicyEditor content={policy.content as JSONContent[]} />
71+
)}
7272
</div>
7373
<p className="text-muted-foreground mt-4 text-sm">
7474
Status: {policy.status}{' '}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client';
2+
3+
import { Card, CardContent } from '@comp/ui/card';
4+
import { FileText, Loader2 } from 'lucide-react';
5+
import { useAction } from 'next-safe-action/hooks';
6+
import { useEffect, useState } from 'react';
7+
import { toast } from 'sonner';
8+
import { getPolicyPdfUrl } from '../../../actions/getPolicyPdfUrl';
9+
10+
interface PortalPdfViewerProps {
11+
policyId: string;
12+
s3Key?: string | null;
13+
}
14+
15+
export function PortalPdfViewer({ policyId, s3Key }: PortalPdfViewerProps) {
16+
const [signedUrl, setSignedUrl] = useState<string | null>(null);
17+
const [isLoading, setIsLoading] = useState(true);
18+
19+
const { execute: getUrl } = useAction(getPolicyPdfUrl, {
20+
onSuccess: (result) => {
21+
const url = result?.data?.data ?? null;
22+
if (result?.data?.success && url) {
23+
setSignedUrl(url);
24+
} else {
25+
setSignedUrl(null);
26+
toast.error('Could not load the policy document.');
27+
}
28+
},
29+
onError: () => toast.error('An error occurred while loading the policy.'),
30+
onSettled: () => setIsLoading(false),
31+
});
32+
33+
useEffect(() => {
34+
if (s3Key) {
35+
getUrl({ policyId });
36+
} else {
37+
setIsLoading(false);
38+
}
39+
}, [s3Key, policyId, getUrl]);
40+
41+
if (isLoading) {
42+
return (
43+
<div className="flex h-[500px] w-full items-center justify-center rounded-md border">
44+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
45+
</div>
46+
);
47+
}
48+
49+
if (signedUrl) {
50+
return (
51+
<iframe
52+
key={signedUrl}
53+
src={signedUrl}
54+
className="h-[500px] w-full rounded-md border"
55+
title="Policy Document"
56+
/>
57+
);
58+
}
59+
60+
// Fallback UI if there's no PDF or an error occurs
61+
return (
62+
<Card className="flex h-[500px] w-full flex-col items-center justify-center">
63+
<CardContent className="text-center">
64+
<FileText className="mx-auto h-12 w-12 text-muted-foreground" />
65+
<p className="mt-4 font-semibold">PDF Document Not Available</p>
66+
<p className="text-sm text-muted-foreground">
67+
This policy is stored as a PDF, but it could not be loaded.
68+
</p>
69+
</CardContent>
70+
</Card>
71+
);
72+
}
Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,22 @@
11
'use client';
22

3+
import type { Policy } from '@db';
34
import type { JSONContent } from '@tiptap/react';
45
import { PolicyEditor } from '../../components/policy/PolicyEditor';
6+
import { PortalPdfViewer } from '../../components/policy/PortalPdfViewer';
57

68
interface PolicyViewerProps {
7-
content: JSONContent | JSONContent[] | unknown;
9+
policy: Policy;
810
}
911

10-
export default function PolicyViewer({ content }: PolicyViewerProps) {
11-
// Convert content to array format if needed
12-
let contentArray: JSONContent[];
13-
14-
if (Array.isArray(content)) {
15-
contentArray = content as JSONContent[];
16-
} else if (typeof content === 'object' && content !== null) {
17-
// If it's a single JSONContent object, wrap it in an array
18-
contentArray = [content as JSONContent];
19-
} else {
20-
// Fallback for string or other formats
21-
contentArray = [
22-
{
23-
type: 'paragraph',
24-
content: [
25-
{
26-
type: 'text',
27-
text: typeof content === 'string' ? content : 'Policy content not available',
28-
},
29-
],
30-
},
31-
];
12+
export default function PolicyViewer({ policy }: PolicyViewerProps) {
13+
if (policy.displayFormat === 'PDF') {
14+
return <PortalPdfViewer policyId={policy.id} s3Key={policy.pdfUrl} />;
3215
}
3316

17+
const contentArray = (
18+
Array.isArray(policy.content) ? policy.content : [policy.content]
19+
) as JSONContent[];
20+
3421
return <PolicyEditor content={contentArray} />;
3522
}

apps/portal/src/app/(app)/(home)/[orgId]/policy/[policyId]/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export default async function PolicyPage({
6565
{isAccepted && (
6666
<div className="bg-green-50 border-green-200 mb-4 flex items-center gap-2 rounded-t-xs border p-3">
6767
<Check className="text-green-600 h-5 w-5" />
68-
<span className="text-green-800 text-sm font-medium">
68+
<span className="text-green-800 text-sm font-medium">
6969
You have accepted this policy
7070
</span>
7171
</div>
@@ -79,8 +79,8 @@ export default async function PolicyPage({
7979
)}
8080
</CardHeader>
8181
<CardContent>
82-
<div className="prose max-w-none">
83-
<PolicyViewer content={policy.content} />
82+
<div className="prose max-w-none">
83+
<PolicyViewer policy={policy} />
8484
</div>
8585
{policy.updatedAt && (
8686
<p className="text-muted-foreground mt-6 text-sm">
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { BUCKET_NAME, s3Client } from '@/utils/s3';
5+
import { GetObjectCommand } from '@aws-sdk/client-s3';
6+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
7+
import { db } from '@db';
8+
import { z } from 'zod';
9+
10+
export const getPolicyPdfUrl = authActionClient
11+
.inputSchema(z.object({ policyId: z.string() }))
12+
.metadata({
13+
name: 'getPolicyPdfUrl',
14+
track: {
15+
event: 'portal-get-policy-pdf-url',
16+
channel: 'server',
17+
},
18+
})
19+
.action(async ({ parsedInput, ctx }) => {
20+
const { policyId } = parsedInput;
21+
const { user } = ctx;
22+
23+
if (!user) {
24+
throw new Error('Unauthorized');
25+
}
26+
27+
try {
28+
const policy = await db.policy.findUnique({
29+
where: { id: policyId, status: 'published' },
30+
select: { pdfUrl: true, organizationId: true },
31+
});
32+
33+
if (!policy?.pdfUrl) {
34+
return { success: false, error: 'No PDF found for this policy.' };
35+
}
36+
37+
const member = await db.member.findFirst({
38+
where: { userId: user.id, organizationId: policy.organizationId },
39+
});
40+
41+
if (!member) {
42+
return { success: false, error: 'Access denied.' };
43+
}
44+
45+
const command = new GetObjectCommand({
46+
Bucket: BUCKET_NAME,
47+
Key: policy.pdfUrl,
48+
});
49+
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 });
50+
51+
return { success: true, data: signedUrl };
52+
} catch (error) {
53+
console.error('Error generating signed URL for portal:', error);
54+
return { success: false, error: 'Could not retrieve PDF.' };
55+
}
56+
});

0 commit comments

Comments
 (0)