diff --git a/apps/blog-platform/package.json b/apps/blog-platform/package.json index 8c74363..686f5ac 100644 --- a/apps/blog-platform/package.json +++ b/apps/blog-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/blog-platform", - "version": "3.8.0-beta.1", + "version": "3.8.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/docs-platform/package.json b/apps/docs-platform/package.json index a623e46..36569b9 100644 --- a/apps/docs-platform/package.json +++ b/apps/docs-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/docs-platform", - "version": "3.8.0-beta.1", + "version": "3.8.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/server/package.json b/apps/server/package.json index de7cd16..504d7c6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/controllers/shareController.ts b/apps/server/src/controllers/shareController.ts index db528b9..4ad1bc6 100644 --- a/apps/server/src/controllers/shareController.ts +++ b/apps/server/src/controllers/shareController.ts @@ -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)), @@ -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}`); @@ -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(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. * @@ -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")); diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index 6792481..547d901 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -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"; @@ -63,8 +64,7 @@ export async function getSessionUserFromRequest(req: Request): Promise link.documentId))]; + } + static async listShareLinksPaginated( userId: string, documentId: string, diff --git a/apps/server/src/utils/authCache.ts b/apps/server/src/utils/authCache.ts index c0fb694..66003d7 100644 --- a/apps/server/src/utils/authCache.ts +++ b/apps/server/src/utils/authCache.ts @@ -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 @@ -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 { const cacheKey = getSessionCacheKey(cookieHeader); + if (cacheKey) { await cacheDel(cacheKey); } diff --git a/apps/server/tests/auth/session-cache.test.ts b/apps/server/tests/auth/session-cache.test.ts new file mode 100644 index 0000000..4ce7f9f --- /dev/null +++ b/apps/server/tests/auth/session-cache.test.ts @@ -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, + ); + }); +}); diff --git a/apps/site/package.json b/apps/site/package.json index 9b6be85..12e00d0 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/site", - "version": "3.8.0-beta.1", + "version": "3.8.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx b/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx index 403f8e0..dcc2212 100644 --- a/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx +++ b/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx @@ -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 ( diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx index 6894202..bb42211 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx @@ -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; @@ -35,13 +35,12 @@ export function DocumentListRow({ onSyncNowAction, onSyncDetailsAction, }: DocumentListRowProps) { - const Icon = docIconMap[doc.type]; const editorPath = getDocumentEditorPath(doc.type, doc.id); return (
- +
diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx index f0629fb..6ae2e63 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx @@ -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; @@ -80,9 +80,9 @@ export function DocumentPreviewCard({
@@ -116,14 +116,12 @@ function DocumentThumbnailPreview({ doc }: { doc: DocumentLibraryItem }) { ); } - const Icon = docIconMap[doc.type]; - return (
- +
diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts b/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts index 7155404..ce5c37f 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts +++ b/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts @@ -1,3 +1,4 @@ +import { createElement } from "react"; import { FileText, Mail } from "lucide-react"; import type { SyncTelemetry } from "@/features/documents/services/document-sync"; @@ -5,10 +6,16 @@ import type { DocumentLibraryItem } from "@/features/documents/services/document import { formatRelative } from "@/features/documents/services/document-library"; -export const docIconMap = { - RESUME: FileText, - COVER_LETTER: Mail, -} satisfies Record; +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"; diff --git a/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts b/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts index 4801bbc..524dd76 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts +++ b/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts @@ -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), []); diff --git a/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx b/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx index 52b8c3c..517e29b 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx @@ -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"; @@ -87,8 +90,11 @@ export default function DocumentsWorkspace() { className="h-10 w-auto min-w-36 rounded-xl px-3 shadow-none" > - - + {DOCUMENT_TYPES.map((type) => ( + + ))}
); -}; +}); interface EditorSectionItemProps { id: ResumeSectionId; diff --git a/apps/studio/features/resume/editor/EditorSettingsPanel.tsx b/apps/studio/features/resume/editor/EditorSettingsPanel.tsx index df5d99d..1fd0172 100644 --- a/apps/studio/features/resume/editor/EditorSettingsPanel.tsx +++ b/apps/studio/features/resume/editor/EditorSettingsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { memo, useState } from "react"; import type { FontFamilyId } from "@/features/documents/constants/fonts"; @@ -10,12 +10,17 @@ import AdvancedThemeSettings from "./settings/AdvancedThemeSettings"; import SectionVisibilitySettings from "./settings/SectionVisibilitySettings"; import { SettingsColor, SettingsRange, SettingsSelect } from "./settings/SettingControls"; -import { useResume } from "@/features/resume/hooks/use-resume"; import { fontOptions } from "@/features/documents/constants/fonts"; +import { useResumeStore } from "@/features/resume/store/resume-store"; import { defaultResume } from "@/features/resume/constants/default-resume"; -const EditorSettingsPanel = () => { - const { resume, setSectionVisibility, setTemplateId, updateCustomization } = useResume(); +const EditorSettingsPanel = memo(function EditorSettingsPanel() { + const sections = useResumeStore((state) => state.resume.sections); + const templateId = useResumeStore((state) => state.resume.templateId); + const customization = useResumeStore((state) => state.resume.customization); + const setSectionVisibility = useResumeStore((state) => state.setSectionVisibility); + const setTemplateId = useResumeStore((state) => state.setTemplateId); + const updateCustomization = useResumeStore((state) => state.updateCustomization); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -31,7 +36,7 @@ const EditorSettingsPanel = () => { setTemplateId(event.target.value)} > {templateSummaries.map((template) => ( @@ -48,7 +53,7 @@ const EditorSettingsPanel = () => { fontFamily: event.target.value as FontFamilyId, }) } - value={resume.customization.fontFamily} + value={customization.fontFamily} > {fontOptions.map((font) => (
); -}; +}); export default EditorSettingsPanel; diff --git a/apps/studio/features/resume/editor/ResumeEditorModals.tsx b/apps/studio/features/resume/editor/ResumeEditorModals.tsx index 2293501..d9c84c7 100644 --- a/apps/studio/features/resume/editor/ResumeEditorModals.tsx +++ b/apps/studio/features/resume/editor/ResumeEditorModals.tsx @@ -4,9 +4,9 @@ import { toast } from "sonner"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useResume } from "@/features/resume/hooks/use-resume"; -import { getDocumentEditorPath } from "@/features/documents/core/routes"; +import { useResumeStore } from "@/features/resume/store/resume-store"; import { DocumentApi } from "@/features/documents/services/document-api"; +import { getDocumentEditorPath } from "@/features/documents/core/routes"; import { trackUsageEvent } from "@/features/analytics/services/usage-metrics"; import { deleteResume, createResume } from "@/features/resume/services/resume-service"; @@ -27,7 +27,8 @@ const EditorModals = ({ onDeleteModalClose, }: EditorModalsProps) => { const router = useRouter(); - const { resume, setResume } = useResume(); + const resume = useResumeStore((state) => state.resume); + const setResume = useResumeStore((state) => state.setResume); const [isDeleting, setIsDeleting] = useState(false); async function onDeleteResume() { @@ -65,6 +66,7 @@ const EditorModals = ({ {shareModalOpen && ( diff --git a/apps/studio/features/resume/editor/ResumeToolbar.tsx b/apps/studio/features/resume/editor/ResumeToolbar.tsx index 944820a..52dc382 100644 --- a/apps/studio/features/resume/editor/ResumeToolbar.tsx +++ b/apps/studio/features/resume/editor/ResumeToolbar.tsx @@ -13,7 +13,7 @@ import ToolbarDownloadMenu from "@/features/documents/editor/toolbar/ToolbarDown import { useToolbarDownloads } from "@/features/resume/editor/toolbar/useToolbarDownloads"; import ToolbarSecondaryActions from "@/features/resume/editor/toolbar/ToolbarSecondaryActions"; -import { useResume } from "@/features/resume/hooks/use-resume"; +import { useResumeStore } from "@/features/resume/store/resume-store"; import { getDocumentEditorPath } from "@/features/documents/core/routes"; import { saveResume, importResumeFromFile } from "@/features/resume/services/resume-service"; @@ -29,7 +29,9 @@ const ResumeToolbar = ({ resumeId, resumePreviewId, onOpenShare, onOpenDelete }: const fileInputRef = useRef(null); - const { resetResume, resume, setResume } = useResume(); + const resume = useResumeStore((state) => state.resume); + const resetResume = useResumeStore((state) => state.resetResume); + const setResume = useResumeStore((state) => state.setResume); const [message, setMessage] = useState("Autosave ready"); diff --git a/apps/studio/features/resume/editor/content/sections/AchievementsSection.tsx b/apps/studio/features/resume/editor/content/sections/AchievementsSection.tsx index 060baa6..d2e33c3 100644 --- a/apps/studio/features/resume/editor/content/sections/AchievementsSection.tsx +++ b/apps/studio/features/resume/editor/content/sections/AchievementsSection.tsx @@ -1,96 +1,28 @@ "use client"; -import { useState } from "react"; - import type { BaseSectionProps } from "./section-types"; import { Input } from "@veriworkly/ui"; -import { Button } from "@veriworkly/ui"; -import DraggableSection from "./DraggableSection"; import { Field, TextArea } from "../EditorFormPrimitives"; +import GenericCustomSection from "./GenericCustomSection"; -import { useResumeStore } from "@/features/resume/store/resume-store"; - -const AchievementsSection = ({ - isOpen, - onDragEnd, - onDragOver, - onDragStart, - onDrop, - onToggle, -}: BaseSectionProps) => { - const achievementsSection = - useResumeStore((state) => - state.resume.customSections.find((section) => section.kind === "achievements"), - ) ?? null; - const addCustomSectionItem = useResumeStore((state) => state.addCustomSectionItem); - const removeCustomSectionItem = useResumeStore((state) => state.removeCustomSectionItem); - const updateCustomSectionItem = useResumeStore((state) => state.updateCustomSectionItem); - - const [achievementIndex, setAchievementIndex] = useState(0); - - if (!achievementsSection) { - return null; - } - - const safeAchievementIndex = Math.min( - achievementIndex, - Math.max(0, achievementsSection.items.length - 1), - ); - - const activeAchievement = achievementsSection.items[safeAchievementIndex]; - +const AchievementsSection = (props: BaseSectionProps) => { return ( - -
- {achievementsSection.items.length ? ( - - ) : null} - - - - -
- - {activeAchievement ? ( + {({ item: activeAchievement, update }) => ( <>
- updateCustomSectionItem("achievements", safeAchievementIndex, { - name: event.target.value, - }) - } + onChange={(event) => update({ name: event.target.value })} value={activeAchievement.name} /> @@ -99,20 +31,14 @@ const AchievementsSection = ({