@tribe-nest/forge 3.37.0 → 3.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.37.0",
3
+ "version": "3.38.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,6 +8,9 @@ export {
8
8
  useWorkInvite,
9
9
  useAcceptWorkInvite,
10
10
  useWorkProjectInvoices,
11
+ useWorkSharedDocuments,
12
+ useWorkOpenSharedDocument,
13
+ useUploadWorkDocument,
11
14
  formatWorkMoney,
12
15
  formatWorkDate,
13
16
  type WorkProjectSummary,
@@ -21,5 +24,6 @@ export {
21
24
  type WorkInviteContext,
22
25
  type WorkInviteAcceptResult,
23
26
  type WorkProjectInvoice,
27
+ type WorkSharedDocument,
24
28
  type UseWorkTaskCommentsResult,
25
29
  } from "./useWorkPortal";
@@ -1,4 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
2
+ import axios from "axios";
2
3
  import { useForge } from "../../../provider/ForgeProvider";
3
4
 
4
5
  /**
@@ -89,6 +90,25 @@ export interface WorkTaskAttachment {
89
90
  size: number | null;
90
91
  }
91
92
 
93
+ /**
94
+ * A case-file document the firm has shared with the client, or one the client
95
+ * put there themselves. A file document describes itself but never carries an
96
+ * address: the bytes come only through `useWorkOpenSharedDocument`.
97
+ */
98
+ export interface WorkSharedDocument {
99
+ id: string;
100
+ title: string;
101
+ docType: "page" | "file";
102
+ updatedAt: string;
103
+ sharedAt: string;
104
+ file: {
105
+ mediaId: string;
106
+ filename: string | null;
107
+ size: number;
108
+ type: string;
109
+ } | null;
110
+ }
111
+
92
112
  export interface WorkProjectInvoice {
93
113
  id: string;
94
114
  invoiceNumber: string;
@@ -110,7 +130,7 @@ export interface WorkInviteContext {
110
130
  firstName: string | null;
111
131
  /**
112
132
  * The invited address already has an account, so the invite grants portal
113
- * access but cannot set a credential — render "sign in", not a password
133
+ * access but cannot set a credential: render "sign in", not a password
114
134
  * form (C10).
115
135
  */
116
136
  requiresExistingLogin?: boolean;
@@ -124,7 +144,7 @@ export interface WorkInviteAcceptResult {
124
144
  */
125
145
  requiresLogin?: boolean;
126
146
  token?: string;
127
- /** Rotating refresh token for the portal session (backend §7) — persist it. */
147
+ /** Rotating refresh token for the portal session (backend §7). Persist it. */
128
148
  refreshToken?: string;
129
149
  projectId: string;
130
150
  account: { id: string; email: string; firstName: string | null; lastName: string | null };
@@ -142,12 +162,14 @@ const keys = {
142
162
  ["work-portal-task-attachments", profileId, projectId, taskId] as const,
143
163
  projectInvoices: (profileId: string | undefined, projectId: string) =>
144
164
  ["work-portal-project-invoices", profileId, projectId] as const,
165
+ sharedDocuments: (profileId: string | undefined, projectId: string) =>
166
+ ["work-portal-shared-documents", profileId, projectId] as const,
145
167
  };
146
168
 
147
169
  // ── Authed client's projects ─────────────────────────────────────────────────
148
170
 
149
171
  /** The authenticated client's (non-archived) client projects with a task/done
150
- * summary — GET /public/work/projects. The token is the identity. */
172
+ * summary: GET /public/work/projects. The token is the identity. */
151
173
  export function useWorkPortalProjects(): UseQueryResult<WorkProjectSummary[]> {
152
174
  const { client, profileId } = useForge();
153
175
  return useQuery<WorkProjectSummary[]>({
@@ -163,7 +185,7 @@ export function useWorkPortalProjects(): UseQueryResult<WorkProjectSummary[]> {
163
185
  // ── Authed client's single-project report ────────────────────────────────────
164
186
 
165
187
  /** The authenticated client's overview + billable-only time report for one of
166
- * their projects — GET /public/work/projects/:id/report. */
188
+ * their projects: GET /public/work/projects/:id/report. */
167
189
  export function useWorkProjectReport(projectId: string): UseQueryResult<WorkProjectReportData> {
168
190
  const { client, profileId } = useForge();
169
191
  return useQuery<WorkProjectReportData>({
@@ -180,7 +202,7 @@ export function useWorkProjectReport(projectId: string): UseQueryResult<WorkProj
180
202
 
181
203
  // ── Public tokenized report (no login) ───────────────────────────────────────
182
204
 
183
- /** Login-free tokenized share of a project's time report — GET
205
+ /** Login-free tokenized share of a project's time report: GET
184
206
  * /public/work/time-report/:token. The signed token IS the authorization. */
185
207
  export function useWorkTokenReport(token: string): UseQueryResult<WorkProjectReportData> {
186
208
  const { client } = useForge();
@@ -208,7 +230,7 @@ export interface UseWorkTaskCommentsResult {
208
230
 
209
231
  /**
210
232
  * The authenticated client's comment thread for a single task on one of their
211
- * projects — GET/POST /public/work/projects/:projectId/tasks/:taskId/comments.
233
+ * projects: GET/POST /public/work/projects/:projectId/tasks/:taskId/comments.
212
234
  * Newest first. The bearer token IS the client identity (server-enforced).
213
235
  * Posting invalidates the thread so the new comment appears. Tier-1 primitive:
214
236
  * returns clean data + a bound mutation, renders nothing.
@@ -259,7 +281,7 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
259
281
 
260
282
  /**
261
283
  * Read-only attachments the team has added to a task on one of the client's
262
- * projects — GET /public/work/projects/:projectId/tasks/:taskId/attachments.
284
+ * projects: GET /public/work/projects/:projectId/tasks/:taskId/attachments.
263
285
  * Tier-1 primitive: returns clean data, renders nothing.
264
286
  */
265
287
  export function useWorkTaskAttachments(projectId: string, taskId: string): UseQueryResult<WorkTaskAttachment[]> {
@@ -279,7 +301,7 @@ export function useWorkTaskAttachments(projectId: string, taskId: string): UseQu
279
301
  // ── Authed client's invoices for a project ───────────────────────────────────
280
302
 
281
303
  /**
282
- * The client's (non-draft) invoices for one of their projects —
304
+ * The client's (non-draft) invoices for one of their projects:
283
305
  * GET /public/work/projects/:id/invoices. Each carries a `payable` flag the
284
306
  * portal turns into a Pay button (links to /i/invoice/:id). Tier-1 primitive.
285
307
  */
@@ -295,11 +317,104 @@ export function useWorkProjectInvoices(projectId: string): UseQueryResult<WorkPr
295
317
  });
296
318
  }
297
319
 
320
+ // ── Authed client's case-file documents ──────────────────────────────────────
321
+
322
+ /**
323
+ * The documents on one of the client's projects that they may see: what the
324
+ * firm shared with them, and what they uploaded themselves:
325
+ * GET /public/work/projects/:id/documents. Tier-1 primitive.
326
+ */
327
+ export function useWorkSharedDocuments(projectId: string): UseQueryResult<WorkSharedDocument[]> {
328
+ const { client, profileId } = useForge();
329
+ return useQuery<WorkSharedDocument[]>({
330
+ queryKey: keys.sharedDocuments(profileId, projectId),
331
+ queryFn: async () => {
332
+ const res = await client.get(`/public/work/projects/${projectId}/documents`, { params: { profileId } });
333
+ return res.data as WorkSharedDocument[];
334
+ },
335
+ enabled: !!profileId && !!client && !!projectId,
336
+ });
337
+ }
338
+
339
+ /**
340
+ * A sixty-second, single-use GET at one shared file's bytes:
341
+ * GET /public/work/projects/:id/documents/:documentId/content. Meant to be
342
+ * followed once, by the click that asked for it; never cache or render the URL.
343
+ */
344
+ export function useWorkOpenSharedDocument(projectId: string) {
345
+ const { client, profileId } = useForge();
346
+ return useMutation<{ url: string }, unknown, string>({
347
+ mutationFn: async (documentId: string) => {
348
+ const res = await client.get(`/public/work/projects/${projectId}/documents/${documentId}/content`, {
349
+ params: { profileId },
350
+ });
351
+ return res.data as { url: string };
352
+ },
353
+ });
354
+ }
355
+
356
+ const mediaTypeFor = (file: File): "image" | "video" | "audio" | "document" | "other" => {
357
+ const mime = file.type || "";
358
+ if (mime.startsWith("image/")) return "image";
359
+ if (mime.startsWith("video/")) return "video";
360
+ if (mime.startsWith("audio/")) return "audio";
361
+ if (mime.startsWith("text/") || mime.includes("pdf") || mime.includes("word") || mime.includes("document")) {
362
+ return "document";
363
+ }
364
+ return "other";
365
+ };
366
+
367
+ /**
368
+ * Put a file into the client's matter, in three steps that must stay in this
369
+ * order:
370
+ *
371
+ * 1. sign a PUT into the firm's PRIVATE bucket
372
+ * (POST /public/work/projects/:id/documents/upload-url);
373
+ * 2. send the bytes straight to object storage, with the Content-Type the
374
+ * server SIGNED (derived from the extension allowlist, not the browser);
375
+ * 3. register the document (POST /public/work/projects/:id/documents), which
376
+ * writes the row, shares it back to the client and queues extraction.
377
+ *
378
+ * A failure at any step is a failure of the whole upload. On success the
379
+ * shared-documents list is refetched so the new file appears at once.
380
+ */
381
+ export function useUploadWorkDocument(projectId: string) {
382
+ const { client, profileId } = useForge();
383
+ const queryClient = useQueryClient();
384
+ return useMutation<WorkSharedDocument, unknown, { file: File; title?: string }>({
385
+ mutationFn: async ({ file, title }) => {
386
+ const { data: signed } = await client.post<{
387
+ presignedUrl: string;
388
+ key: string;
389
+ requiredHeaders?: Record<string, string>;
390
+ }>(`/public/work/projects/${projectId}/documents/upload-url`, { fileName: file.name }, { params: { profileId } });
391
+ await axios.put(signed.presignedUrl, file, {
392
+ headers: signed.requiredHeaders ?? { "Content-Type": file.type || "application/octet-stream" },
393
+ });
394
+ const { data } = await client.post(
395
+ `/public/work/projects/${projectId}/documents`,
396
+ {
397
+ storageKey: signed.key,
398
+ fileName: file.name,
399
+ fileSize: file.size,
400
+ mediaType: mediaTypeFor(file),
401
+ ...(title ? { title } : {}),
402
+ },
403
+ { params: { profileId } },
404
+ );
405
+ return data as WorkSharedDocument;
406
+ },
407
+ onSuccess: () => {
408
+ void queryClient.invalidateQueries({ queryKey: keys.sharedDocuments(profileId, projectId) });
409
+ },
410
+ });
411
+ }
412
+
298
413
  // ── Client invite accept (public, no auth) ───────────────────────────────────
299
414
 
300
415
  /**
301
416
  * Fetch the branded context for an invite link (GET /public/work/invite).
302
- * Public — no bearer token needed; the link's token IS the credential.
417
+ * Public: no bearer token needed; the link's token IS the credential.
303
418
  * `null`/errored when the token or project is missing/invalid/expired.
304
419
  */
305
420
  export function useWorkInvite(
@@ -321,7 +436,7 @@ export function useWorkInvite(
321
436
  /**
322
437
  * Accept an invite (POST /public/work/invite/accept): set a password and
323
438
  * receive a fresh portal session token. The caller persists the token +
324
- * navigates. Public — no bearer token needed.
439
+ * navigates. Public: no bearer token needed.
325
440
  */
326
441
  export function useAcceptWorkInvite() {
327
442
  const { client } = useForge();
@@ -0,0 +1,224 @@
1
+ import { useRef, useState } from "react";
2
+ import { FileText, Paperclip, Upload } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import {
5
+ formatWorkDate,
6
+ useUploadWorkDocument,
7
+ useWorkOpenSharedDocument,
8
+ useWorkSharedDocuments,
9
+ type WorkSharedDocument,
10
+ } from "../../headless/work/useWorkPortal";
11
+
12
+ export interface WorkProjectDocumentsProps {
13
+ /** The client project id. */
14
+ projectId: string;
15
+ /** Hide the upload control. The list still renders. Defaults to `true`. */
16
+ allowUpload?: boolean;
17
+ }
18
+
19
+ const humanSize = (size: number | null | undefined): string => {
20
+ if (!size || size <= 0) return "";
21
+ const units = ["B", "KB", "MB", "GB"];
22
+ let n = size;
23
+ let i = 0;
24
+ while (n >= 1024 && i < units.length - 1) {
25
+ n /= 1024;
26
+ i += 1;
27
+ }
28
+ return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
29
+ };
30
+
31
+ const errorMessage = (error: unknown): string =>
32
+ (error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
33
+ "The upload didn't go through. Please try again.";
34
+
35
+ /**
36
+ * The documents on a client's project: what the firm shared with them, and
37
+ * what they sent in themselves, with an upload control for the latter.
38
+ *
39
+ * A file opens through a short-lived signed read fetched on the click, never
40
+ * from an address held in the page. Built on `useWorkSharedDocuments`,
41
+ * `useWorkOpenSharedDocument` and `useUploadWorkDocument`; theme tokens only.
42
+ *
43
+ * Unlike the invoices block this renders even when the list is empty, because
44
+ * the empty state is where the upload control lives.
45
+ */
46
+ export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProjectDocumentsProps) {
47
+ const t = useThemeTokens();
48
+ const { data: documents, isLoading } = useWorkSharedDocuments(projectId);
49
+ const open = useWorkOpenSharedDocument(projectId);
50
+ const upload = useUploadWorkDocument(projectId);
51
+ const fileInputRef = useRef<HTMLInputElement>(null);
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [opening, setOpening] = useState<string | null>(null);
54
+
55
+ if (isLoading) return null;
56
+
57
+ const onFiles = async (files: FileList | null) => {
58
+ if (!files || files.length === 0) return;
59
+ setError(null);
60
+ // One at a time, in order: each is its own signed PUT and its own row, and
61
+ // a failure names the file that failed rather than the whole batch.
62
+ for (const file of Array.from(files)) {
63
+ try {
64
+ await upload.mutateAsync({ file });
65
+ } catch (err) {
66
+ setError(`${file.name}: ${errorMessage(err)}`);
67
+ return;
68
+ }
69
+ }
70
+ };
71
+
72
+ const openDocument = async (doc: WorkSharedDocument) => {
73
+ if (!doc.file) return;
74
+ setOpening(doc.id);
75
+ try {
76
+ const { url } = await open.mutateAsync(doc.id);
77
+ window.open(url, "_blank", "noopener");
78
+ } catch (err) {
79
+ setError(errorMessage(err));
80
+ } finally {
81
+ setOpening(null);
82
+ }
83
+ };
84
+
85
+ const list = documents ?? [];
86
+
87
+ return (
88
+ <div
89
+ data-testid="work-project-documents"
90
+ style={{
91
+ border: `1px solid ${t.border}`,
92
+ borderRadius: t.cornerRadius,
93
+ background: t.surface,
94
+ padding: 20,
95
+ color: t.text,
96
+ fontFamily: t.fontFamily,
97
+ }}
98
+ >
99
+ <div
100
+ style={{
101
+ display: "flex",
102
+ alignItems: "center",
103
+ justifyContent: "space-between",
104
+ gap: 12,
105
+ flexWrap: "wrap",
106
+ marginBottom: 14,
107
+ }}
108
+ >
109
+ <h2 style={{ fontSize: 16, fontWeight: 800, margin: 0 }}>Documents</h2>
110
+ {allowUpload && (
111
+ <>
112
+ <input
113
+ ref={fileInputRef}
114
+ type="file"
115
+ multiple
116
+ hidden
117
+ data-testid="work-document-file-input"
118
+ onChange={(e) => {
119
+ void onFiles(e.target.files);
120
+ e.target.value = "";
121
+ }}
122
+ />
123
+ <button
124
+ type="button"
125
+ data-testid="work-document-upload"
126
+ onClick={() => fileInputRef.current?.click()}
127
+ disabled={upload.isPending}
128
+ style={{
129
+ display: "inline-flex",
130
+ alignItems: "center",
131
+ gap: 8,
132
+ padding: "8px 16px",
133
+ borderRadius: t.cornerRadius,
134
+ border: "none",
135
+ background: t.primary,
136
+ color: t.textPrimary,
137
+ fontWeight: 700,
138
+ fontSize: 13,
139
+ cursor: upload.isPending ? "wait" : "pointer",
140
+ opacity: upload.isPending ? 0.7 : 1,
141
+ }}
142
+ >
143
+ <Upload size={14} />
144
+ {upload.isPending ? "Uploading…" : "Upload a document"}
145
+ </button>
146
+ </>
147
+ )}
148
+ </div>
149
+
150
+ {error && (
151
+ <p
152
+ data-testid="work-document-error"
153
+ role="alert"
154
+ style={{ margin: "0 0 12px", fontSize: 13, color: "#ef4444" }}
155
+ >
156
+ {error}
157
+ </p>
158
+ )}
159
+
160
+ {list.length === 0 ? (
161
+ <p data-testid="work-project-documents-empty" style={{ margin: 0, fontSize: 13, color: t.muted }}>
162
+ {allowUpload
163
+ ? "Nothing here yet. Upload a document to add it to this project."
164
+ : "Nothing has been shared with you yet."}
165
+ </p>
166
+ ) : (
167
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
168
+ {list.map((doc) => (
169
+ <li
170
+ key={doc.id}
171
+ data-testid={`work-document-${doc.id}`}
172
+ style={{
173
+ display: "flex",
174
+ alignItems: "center",
175
+ justifyContent: "space-between",
176
+ gap: 12,
177
+ flexWrap: "wrap",
178
+ padding: "10px 14px",
179
+ border: `1px solid ${t.border}`,
180
+ borderRadius: t.cornerRadius,
181
+ }}
182
+ >
183
+ <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
184
+ {doc.file ? (
185
+ <Paperclip size={14} style={{ color: t.muted, flexShrink: 0 }} />
186
+ ) : (
187
+ <FileText size={14} style={{ color: t.muted, flexShrink: 0 }} />
188
+ )}
189
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
190
+ <span style={{ fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
191
+ {doc.title}
192
+ </span>
193
+ <span style={{ fontSize: 12, color: t.muted }}>
194
+ {formatWorkDate(doc.sharedAt)}
195
+ {doc.file?.size ? ` · ${humanSize(doc.file.size)}` : ""}
196
+ </span>
197
+ </div>
198
+ </div>
199
+ {doc.file && (
200
+ <button
201
+ type="button"
202
+ data-testid={`work-document-open-${doc.id}`}
203
+ onClick={() => void openDocument(doc)}
204
+ disabled={opening === doc.id}
205
+ style={{
206
+ background: "transparent",
207
+ border: "none",
208
+ padding: 0,
209
+ color: t.primary,
210
+ fontSize: 13,
211
+ textDecoration: "underline",
212
+ cursor: opening === doc.id ? "wait" : "pointer",
213
+ }}
214
+ >
215
+ {opening === doc.id ? "Opening…" : "Open"}
216
+ </button>
217
+ )}
218
+ </li>
219
+ ))}
220
+ </ul>
221
+ )}
222
+ </div>
223
+ );
224
+ }
@@ -3,6 +3,7 @@ import { Loading } from "../Loading";
3
3
  import { useWorkProjectReport } from "../../headless/work/useWorkPortal";
4
4
  import { WorkReportBody } from "./WorkReportBody";
5
5
  import { WorkProjectInvoices } from "./WorkProjectInvoices";
6
+ import { WorkProjectDocuments } from "./WorkProjectDocuments";
6
7
 
7
8
  export interface WorkProjectReportProps {
8
9
  /** The client project id (from the route). */
@@ -17,7 +18,7 @@ export interface WorkProjectReportProps {
17
18
  }
18
19
 
19
20
  /**
20
- * The authenticated client's read-only project report — milestones, task list
21
+ * The authenticated client's read-only project report: milestones, task list
21
22
  * and the billable-only time report. Each task links to its detail/comments
22
23
  * page. Built on `useWorkProjectReport`; theme tokens only.
23
24
  */
@@ -55,11 +56,12 @@ export function WorkProjectReport({ projectId, taskHref, onOpenTask }: WorkProje
55
56
  ? undefined
56
57
  : (taskHref ?? ((taskId: string) => `/i/work/projects/${projectId}/tasks/${taskId}`));
57
58
 
58
- // The invoices section is authed-only (not part of the login-free token
59
- // share, which renders WorkReportBody directly).
59
+ // The documents and invoices sections are authed-only (not part of the
60
+ // login-free token share, which renders WorkReportBody directly).
60
61
  return (
61
62
  <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
62
63
  <WorkReportBody report={report} taskHref={resolvedTaskHref} onOpenTask={onOpenTask} />
64
+ <WorkProjectDocuments projectId={projectId} />
63
65
  <WorkProjectInvoices projectId={projectId} />
64
66
  </div>
65
67
  );
@@ -8,3 +8,4 @@ export { WorkTaskAttachments, type WorkTaskAttachmentsProps } from "./WorkTaskAt
8
8
  export { WorkTaskDetail, type WorkTaskDetailProps } from "./WorkTaskDetail";
9
9
  export { WorkInviteAccept, type WorkInviteAcceptProps } from "./WorkInviteAccept";
10
10
  export { WorkProjectInvoices, type WorkProjectInvoicesProps } from "./WorkProjectInvoices";
11
+ export { WorkProjectDocuments, type WorkProjectDocumentsProps } from "./WorkProjectDocuments";