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
2 changes: 1 addition & 1 deletion apps/blog-platform/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/blog-platform",
"version": "3.8.0-beta.1",
"version": "3.8.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion apps/docs-platform/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/docs-platform",
"version": "3.8.0-beta.1",
"version": "3.8.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/server",
"version": "3.8.0-beta.1",
"version": "3.8.0",
"description": "VeriWorkly Resume Backend API",
"main": "dist/index.js",
"type": "module",
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/controllers/shareController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ const shareLinkPasswordSchema = z.object({
password: z.string().min(1),
});

const sharedDocumentIdsQuerySchema = z.object({
ids: z
.string()
.optional()
.transform((value) =>
(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
),
});

const publicReadableParamsSchema = z.object({
username: z.string().transform((value) => normalizeUsername(value)),
slug: z.string().transform((value) => normalizeSlug(value)),
Expand Down Expand Up @@ -80,6 +92,7 @@ export class ShareController {
);

await cacheDelByPrefix(`share:list:${user.id}:${documentId}:`);
await cacheDelByPrefix(`share:shared-document-ids:${user.id}:`);

if (previousSlug)
await cacheDel(`share:public-readable:${shareLink.username}:${previousSlug}`);
Expand Down Expand Up @@ -143,6 +156,30 @@ export class ShareController {
}
}

static async listSharedDocumentIds(req: Request, res: Response, next: NextFunction) {
try {
const user = requireAuthUser(req);
const { ids } = sharedDocumentIdsQuerySchema.parse(req.query);
const cacheKey = `share:shared-document-ids:${user.id}:${ids.sort().join(",")}`;

const cached = await cacheGet<unknown>(cacheKey);

if (cached) {
return res.json(createSuccessResponse(cached, "Shared document ids fetched from cache"));
}

const documentIds = await ShareService.listSharedDocumentIds(user.id, ids);
const response = { documentIds };

await cacheSet(cacheKey, response, 300);

res.json(createSuccessResponse(response, "Shared document ids fetched successfully"));
} catch (error) {
if (error instanceof z.ZodError) return next(handleValidationError(error));
next(error);
}
}

/**
* Revoke an existing share link.
*
Expand All @@ -161,6 +198,7 @@ export class ShareController {

// Invalidate caches
await cacheDelByPrefix(`share:list:${user.id}:${documentId}:`);
await cacheDelByPrefix(`share:shared-document-ids:${user.id}:`);
await cacheDel(`share:public-readable:${revoked.username}:${revoked.slug}`);

res.json(createSuccessResponse(null, "Share link revoked successfully"));
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextFunction, Request, Response } from "express";

import { ApiError } from "#utils/errors";
import { config } from "#config";

import { convertNodeHeadersToWebHeaders, getSessionFromRequestHeaders } from "#auth/index";

Expand Down Expand Up @@ -63,8 +64,7 @@ export async function getSessionUserFromRequest(req: Request): Promise<Authentic
name: user.name ?? null,
};

// 5. Cache result in Redis for 15 minutes (short TTL for security)
await cacheSet(cacheKey, authUser, 900);
await cacheSet(cacheKey, authUser, config.auth.sessionCacheMaxAgeSeconds);

req.authUser = authUser;
return authUser;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/routes/shares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ router.post("/public/:username/:slug/verify", ShareController.verifyPublicReadab
router.use(authMiddleware);

router.post("/", ShareController.create);
router.get("/documents/shared-ids", ShareController.listSharedDocumentIds);
router.get("/documents/:documentId", ShareController.list);
router.delete("/documents/:documentId/links/:shareLinkId", ShareController.revoke);

Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/services/shareService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,24 @@ export class ShareService {
return this.listShareLinksPaginated(userId, documentId);
}

static async listSharedDocumentIds(userId: string, documentIds: string[]) {
const uniqueDocumentIds = [...new Set(documentIds.filter(Boolean))];

if (uniqueDocumentIds.length === 0) return [];

const links = await prisma.shareLink.findMany({
where: {
userId,
documentId: { in: uniqueDocumentIds },
document: { deletedAt: null },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
select: { documentId: true },
});

return [...new Set(links.map((link) => link.documentId))];
}

static async listShareLinksPaginated(
userId: string,
documentId: string,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/utils/authCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { cacheDel } from "./redis";

import { cacheDel } from "./redis.js";

export function extractStableAuthCookieFingerprint(cookieHeader: string): string | null {
const authCookies = cookieHeader
Expand Down Expand Up @@ -27,13 +28,16 @@ export function extractStableAuthCookieFingerprint(cookieHeader: string): string

export function getSessionCacheKey(cookieHeader: string): string | null {
const fingerprint = extractStableAuthCookieFingerprint(cookieHeader);

if (!fingerprint) return null;

const cookieHash = createHash("md5").update(fingerprint).digest("hex");
return `auth:session:${cookieHash}`;
}

export async function invalidateSessionCache(cookieHeader: string): Promise<void> {
const cacheKey = getSessionCacheKey(cookieHeader);

if (cacheKey) {
await cacheDel(cacheKey);
}
Expand Down
61 changes: 61 additions & 0 deletions apps/server/tests/auth/session-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const cacheGetMock = vi.fn();
const cacheSetMock = vi.fn();
const getSessionMock = vi.fn();

vi.mock("../../src/config", () => ({
config: {
auth: {
sessionCacheMaxAgeSeconds: 123,
},
},
}));

vi.mock("../../src/utils/redis", () => ({
cacheGet: cacheGetMock,
cacheSet: cacheSetMock,
}));

vi.mock("../../src/auth/index", () => ({
convertNodeHeadersToWebHeaders: (headers: unknown) => headers,
getSessionFromRequestHeaders: getSessionMock,
}));

describe("session auth cache", () => {
beforeEach(() => {
cacheGetMock.mockReset();
cacheSetMock.mockReset();
getSessionMock.mockReset();
});

it("uses configured session cache TTL", async () => {
cacheGetMock.mockResolvedValue(null);
getSessionMock.mockResolvedValue({
user: {
id: "user-1",
email: "user@example.com",
name: "User",
},
});

const { getSessionUserFromRequest } = await import("../../src/middleware/auth");

const user = await getSessionUserFromRequest({
headers: {
cookie: "veriworkly-auth.session_token=token-1",
},
} as never);

expect(user?.id).toBe("user-1");
expect(cacheSetMock).toHaveBeenCalledWith(
expect.stringMatching(/^auth:session:/),
{
id: "user-1",
email: "user@example.com",
name: "User",
},
123,
);
});
});
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/site",
"version": "3.8.0-beta.1",
"version": "3.8.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ const OverviewHome = () => {
() => DOCUMENT_LIBRARY_SERVER_SNAPSHOT,
);

const totalCount = snapshot.counts.RESUME + snapshot.counts.COVER_LETTER;
const totalCount = Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);

const resumeCount = snapshot.counts.RESUME;
const coverLetterCount = snapshot.counts.COVER_LETTER;

const recentDocs = snapshot.docs.slice(0, 6);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { Badge, Button } from "@veriworkly/ui";
import type { SyncTelemetry } from "@/features/documents/services/document-sync";
import type { DocumentLibraryItem } from "@/features/documents/services/document-library";

import { getDocumentDefinition } from "@/features/documents/core/registry";
import { getDocumentEditorPath } from "@/features/documents/core/routes";
import { getDocumentDefinition } from "@/features/documents/core/registry";
import { formatRelative } from "@/features/documents/services/document-library";

import { DocumentActionsMenu } from "./DocumentActionsMenu";
import { docIconMap, getSyncLabel, getActivityLabel } from "./document-display";
import { getSyncLabel, getActivityLabel, LibraryDocumentIcon } from "./document-display";

interface DocumentListRowProps {
doc: DocumentLibraryItem;
Expand All @@ -35,13 +35,12 @@ export function DocumentListRow({
onSyncNowAction,
onSyncDetailsAction,
}: DocumentListRowProps) {
const Icon = docIconMap[doc.type];
const editorPath = getDocumentEditorPath(doc.type, doc.id);

return (
<article className="grid gap-3 p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center sm:p-5">
<div className="border-border bg-background flex h-12 w-10 shrink-0 items-center justify-center rounded-lg border sm:h-14 sm:w-11">
<Icon className="text-accent h-5 w-5" />
<LibraryDocumentIcon className="text-accent h-5 w-5" type={doc.type} />
</div>

<div className="min-w-0 flex-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { Badge } from "@veriworkly/ui";
import type { SyncTelemetry } from "@/features/documents/services/document-sync";
import type { DocumentLibraryItem } from "@/features/documents/services/document-library";

import { getDocumentDefinition } from "@/features/documents/core/registry";
import { getDocumentEditorPath } from "@/features/documents/core/routes";
import { getDocumentDefinition } from "@/features/documents/core/registry";
import { formatRelative } from "@/features/documents/services/document-library";

import { DocumentActionsMenu } from "./DocumentActionsMenu";
import { docIconMap, getSyncLabel, getActivityLabel } from "./document-display";
import { getSyncLabel, getActivityLabel, LibraryDocumentIcon } from "./document-display";

interface DocumentPreviewCardProps {
doc: DocumentLibraryItem;
Expand Down Expand Up @@ -80,9 +80,9 @@ export function DocumentPreviewCard({
</div>

<Link
href={editorPath}
aria-label={`Open ${doc.title}`}
className="absolute inset-0 z-20 cursor-pointer"
href={editorPath}
/>

<div className="absolute top-2 right-2 z-30 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
Expand Down Expand Up @@ -116,14 +116,12 @@ function DocumentThumbnailPreview({ doc }: { doc: DocumentLibraryItem }) {
);
}

const Icon = docIconMap[doc.type];

return (
<div className="absolute inset-0 p-4">
<div className="border-border bg-card h-full rounded-xl border p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="bg-accent/10 text-accent flex h-9 w-9 items-center justify-center rounded-lg">
<Icon className="h-5 w-5" />
<LibraryDocumentIcon className="h-5 w-5" type={doc.type} />
</div>

<div className="grid flex-1 gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { createElement } from "react";
import { FileText, Mail } from "lucide-react";

import type { SyncTelemetry } from "@/features/documents/services/document-sync";
import type { DocumentLibraryItem } from "@/features/documents/services/document-library";

import { formatRelative } from "@/features/documents/services/document-library";

export const docIconMap = {
RESUME: FileText,
COVER_LETTER: Mail,
} satisfies Record<DocumentLibraryItem["type"], typeof FileText>;
interface LibraryDocumentIconProps {
className?: string;
type: DocumentLibraryItem["type"];
}

export function LibraryDocumentIcon({ className, type }: LibraryDocumentIconProps) {
if (type === "COVER_LETTER") return createElement(Mail, { "aria-hidden": true, className });

return createElement(FileText, { "aria-hidden": true, className });
}

export function getSyncLabel(sync: DocumentLibraryItem["sync"]) {
if (!sync.enabled) return "Local only";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function useDocumentsWorkspace() {
);

const { docs, counts } = snapshot;
const totalCount = counts.RESUME + counts.COVER_LETTER;
const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0);

const bump = useCallback(() => setRefreshKey((key) => key + 1), []);

Expand Down
10 changes: 8 additions & 2 deletions apps/studio/app/(main)/(dashboard)/documents/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import SyncDetailsModal from "@/components/modals/SyncDetailsModal";
import ShareDocumentModal from "@/components/modals/ShareDocumentModal";
import RenameDocumentModal from "@/components/modals/RenameDocumentModal";

import { DOCUMENT_TYPES } from "@/features/documents/core/document-types";
import { getDocumentDefinition } from "@/features/documents/core/registry";

import { DocumentListRow } from "./components/DocumentListRow";
import { IconToggle } from "./components/DocumentWorkspaceControls";
import { DocumentPreviewCard } from "./components/DocumentPreviewCard";
Expand Down Expand Up @@ -87,8 +90,11 @@ export default function DocumentsWorkspace() {
className="h-10 w-auto min-w-36 rounded-xl px-3 shadow-none"
>
<option value="ALL">All documents ({totalCount})</option>
<option value="RESUME">Resume ({counts.RESUME})</option>
<option value="COVER_LETTER">Cover letter ({counts.COVER_LETTER})</option>
{DOCUMENT_TYPES.map((type) => (
<option key={type} value={type}>
{getDocumentDefinition(type).label} ({counts[type]})
</option>
))}
</Select>

<label className="sr-only" htmlFor="document-sort">
Expand Down
Loading
Loading