@tribe-nest/forge 3.37.0 → 3.39.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 +1 -1
- package/src/data/queries/useBroadcasts.ts +62 -7
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/types/models.ts +18 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +6 -2
- package/src/ui/headless/work/index.ts +4 -0
- package/src/ui/headless/work/useWorkPortal.ts +125 -10
- package/src/ui/media/_tests/CallStage.spec.tsx +1 -0
- package/src/ui/styled/_tests/broadcastStage.spec.ts +316 -0
- package/src/ui/styled/_tests/uiEntryServerSafety.spec.ts +127 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +233 -14
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +260 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +189 -0
- package/src/ui/styled/work/WorkProjectDocuments.tsx +224 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +5 -3
- package/src/ui/styled/work/index.ts +1 -0
package/package.json
CHANGED
|
@@ -38,14 +38,24 @@ export function useValidateBroadcastPass() {
|
|
|
38
38
|
* page (the "broadcast ended, keep this tab open" screen). Reading the detail
|
|
39
39
|
* endpoint is what makes that page survive the end of the stream.
|
|
40
40
|
*/
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The viewer's validated pass, sent on every read that can return playback
|
|
43
|
+
* credentials.
|
|
44
|
+
*
|
|
45
|
+
* A gated broadcast withholds `liveUrl` and `realtimeConfig` from a caller who
|
|
46
|
+
* cannot show one, because the paywall used to be drawn in the browser only:
|
|
47
|
+
* the public read handed the manifest and the realtime session to anybody who
|
|
48
|
+
* asked, and the ticket box in front of it was decoration. Passing it here is
|
|
49
|
+
* what keeps a paying viewer playing.
|
|
50
|
+
*/
|
|
51
|
+
export function useLiveBroadcast(broadcastId?: string, passId?: string) {
|
|
42
52
|
const { client, profileId } = useForge();
|
|
43
53
|
|
|
44
54
|
return useQuery<ILiveBroadcast>({
|
|
45
|
-
queryKey: ["live-broadcast", profileId, broadcastId],
|
|
55
|
+
queryKey: ["live-broadcast", profileId, broadcastId, passId ?? null],
|
|
46
56
|
queryFn: async () => {
|
|
47
57
|
const res = await client.get(`/public/broadcasts/${broadcastId}`, {
|
|
48
|
-
params: { profileId },
|
|
58
|
+
params: { profileId, ...(passId ? { passId } : {}) },
|
|
49
59
|
});
|
|
50
60
|
return res.data;
|
|
51
61
|
},
|
|
@@ -61,14 +71,14 @@ export function useLiveBroadcast(broadcastId?: string) {
|
|
|
61
71
|
* poll, so a mid-stream edit to the title or the thumbnail cannot swap the
|
|
62
72
|
* player out from under someone who is watching.
|
|
63
73
|
*/
|
|
64
|
-
export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000) {
|
|
74
|
+
export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000, passId?: string) {
|
|
65
75
|
const { client, profileId } = useForge();
|
|
66
76
|
|
|
67
77
|
return useQuery<ILiveBroadcast>({
|
|
68
|
-
queryKey: ["live-broadcast-poll", profileId, broadcastId],
|
|
78
|
+
queryKey: ["live-broadcast-poll", profileId, broadcastId, passId ?? null],
|
|
69
79
|
queryFn: async () => {
|
|
70
80
|
const res = await client.get(`/public/broadcasts/${broadcastId}`, {
|
|
71
|
-
params: { profileId },
|
|
81
|
+
params: { profileId, ...(passId ? { passId } : {}) },
|
|
72
82
|
});
|
|
73
83
|
return res.data;
|
|
74
84
|
},
|
|
@@ -138,6 +148,30 @@ export type BroadcastSubscribeAnswer = {
|
|
|
138
148
|
sessionDescription: { sdp: string; type: string };
|
|
139
149
|
};
|
|
140
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A join ticket for a broadcast's live room on the platform's media plane.
|
|
153
|
+
*
|
|
154
|
+
* A superset of what the media SDK reads (`mediaUrl` and `token` are the only
|
|
155
|
+
* two it touches). The rest is what the server says about the ticket it just
|
|
156
|
+
* minted, useful to a site that wants to show it and ignored by the room.
|
|
157
|
+
*
|
|
158
|
+
* Written out here rather than imported from `@tribe-nest/media-client` on
|
|
159
|
+
* purpose: this module is loaded by every page of every site, and the media
|
|
160
|
+
* SDK is browser-only. A type import would be erased, but the next person to
|
|
161
|
+
* reach for a value from that package would not notice they had crossed the
|
|
162
|
+
* line.
|
|
163
|
+
*/
|
|
164
|
+
export type BroadcastViewerCredentials = {
|
|
165
|
+
/** The signalling entrypoint. A load balancer, never a node the client picked. */
|
|
166
|
+
mediaUrl: string;
|
|
167
|
+
token: string;
|
|
168
|
+
/** ISO. When THIS ticket dies, not when the broadcast does. */
|
|
169
|
+
expiresAt?: string;
|
|
170
|
+
/** This viewer's identity in the live room. Minted per viewer, never reused. */
|
|
171
|
+
identity?: string;
|
|
172
|
+
roomId?: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
141
175
|
/**
|
|
142
176
|
* The imperative half of watching a broadcast: the calls that are made in
|
|
143
177
|
* response to something happening (a pass validated, a heartbeat, a viewer
|
|
@@ -164,18 +198,39 @@ export function useBroadcastSessionApi() {
|
|
|
164
198
|
leave: async (broadcastId: string, sessionId?: string): Promise<void> => {
|
|
165
199
|
await client.post(`/public/broadcasts/${broadcastId}/leave`, { sessionId });
|
|
166
200
|
},
|
|
201
|
+
/**
|
|
202
|
+
* Mint a viewer credential for the broadcast's live room on the media plane.
|
|
203
|
+
*
|
|
204
|
+
* The PASS is the credential here: the endpoint is public and takes no
|
|
205
|
+
* bearer token, and it validates the pass exactly as `validate-session`
|
|
206
|
+
* does before minting anything for a gated broadcast.
|
|
207
|
+
*
|
|
208
|
+
* It answers 409 when the room is full, when realtime is switched off, or
|
|
209
|
+
* when nothing is live. That is a designed answer rather than a fault, and
|
|
210
|
+
* the caller's whole response to it is to watch the HLS stream instead.
|
|
211
|
+
*/
|
|
212
|
+
viewerToken: async (payload: { broadcastId: string; passId?: string }): Promise<BroadcastViewerCredentials> => {
|
|
213
|
+
const res = await client.post(`/streams/v2/public/viewer-token`, payload);
|
|
214
|
+
return res.data;
|
|
215
|
+
},
|
|
167
216
|
/** Subscribe to the WebRTC tracks of a realtime broadcast. */
|
|
168
217
|
subscribe: async (payload: {
|
|
169
218
|
sdp?: string;
|
|
170
219
|
type?: string;
|
|
171
220
|
trackIds?: string[];
|
|
172
221
|
sessionId?: string;
|
|
222
|
+
/** The validated pass. A gated broadcast refuses a subscribe without it. */
|
|
223
|
+
passId?: string;
|
|
173
224
|
}): Promise<BroadcastSubscribeAnswer> => {
|
|
174
225
|
const res = await client.post(`/public/broadcasts/subscribe`, payload);
|
|
175
226
|
return res.data;
|
|
176
227
|
},
|
|
177
228
|
/** Pin the viewer to one simulcast layer, or back to auto. */
|
|
178
|
-
switchQuality: async (payload: {
|
|
229
|
+
switchQuality: async (payload: {
|
|
230
|
+
track: Record<string, unknown>;
|
|
231
|
+
sessionId?: string;
|
|
232
|
+
passId?: string;
|
|
233
|
+
}): Promise<void> => {
|
|
179
234
|
await client.post(`/public/broadcasts/switch-quality`, payload);
|
|
180
235
|
},
|
|
181
236
|
};
|
package/src/i18n/de.json
CHANGED
|
@@ -159,6 +159,9 @@
|
|
|
159
159
|
"forge.broadcast_pass_validation.validating": "Wird geprüft…",
|
|
160
160
|
"forge.broadcast_player.anonymous": "Anonym",
|
|
161
161
|
"forge.broadcast_player.chat": "Chat",
|
|
162
|
+
"forge.broadcast_player.fallback_help": "Der Stream mit geringer Verzögerung hat nur begrenzt Plätze und ist gerade entweder voll oder nicht aktiv. Der Standard-Stream zeigt dieselbe Übertragung einige Sekunden später. Sonst ändert sich nichts an der Seite.",
|
|
163
|
+
"forge.broadcast_player.fallback_help_label": "Warum ist das nicht der Stream mit geringer Verzögerung?",
|
|
164
|
+
"forge.broadcast_player.fallback_notice": "Du siehst den Standard-Stream",
|
|
162
165
|
"forge.broadcast_player.fullscreen": "Vollbild",
|
|
163
166
|
"forge.broadcast_player.message_label": "Nachricht",
|
|
164
167
|
"forge.broadcast_player.pinned": "Angepinnt",
|
|
@@ -168,6 +171,8 @@
|
|
|
168
171
|
"forge.broadcast_player.quality_low": "Niedrig",
|
|
169
172
|
"forge.broadcast_player.quality_medium": "Mittel",
|
|
170
173
|
"forge.broadcast_player.send": "Senden",
|
|
174
|
+
"forge.broadcast_player.sound_off": "Ton ausschalten",
|
|
175
|
+
"forge.broadcast_player.sound_on": "Ton einschalten",
|
|
171
176
|
"forge.broadcast_player.started": "Vor {duration} gestartet",
|
|
172
177
|
"forge.broadcast_player.watching_now": "{count} schauen gerade zu",
|
|
173
178
|
"forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
|
package/src/i18n/en.json
CHANGED
|
@@ -159,6 +159,9 @@
|
|
|
159
159
|
"forge.broadcast_pass_validation.validating": "Validating…",
|
|
160
160
|
"forge.broadcast_player.anonymous": "Anonymous",
|
|
161
161
|
"forge.broadcast_player.chat": "Chat",
|
|
162
|
+
"forge.broadcast_player.fallback_help": "The low-latency stream holds a limited number of viewers, and right now it is either full or not running. The standard stream carries the same broadcast a few seconds behind. Nothing else about the page changes.",
|
|
163
|
+
"forge.broadcast_player.fallback_help_label": "Why is this not the low-latency stream?",
|
|
164
|
+
"forge.broadcast_player.fallback_notice": "Watching the standard stream",
|
|
162
165
|
"forge.broadcast_player.fullscreen": "Fullscreen",
|
|
163
166
|
"forge.broadcast_player.message_label": "Message",
|
|
164
167
|
"forge.broadcast_player.pinned": "Pinned",
|
|
@@ -168,6 +171,8 @@
|
|
|
168
171
|
"forge.broadcast_player.quality_low": "Low",
|
|
169
172
|
"forge.broadcast_player.quality_medium": "Medium",
|
|
170
173
|
"forge.broadcast_player.send": "Send",
|
|
174
|
+
"forge.broadcast_player.sound_off": "Turn sound off",
|
|
175
|
+
"forge.broadcast_player.sound_on": "Turn sound on",
|
|
171
176
|
"forge.broadcast_player.started": "Started {duration} ago",
|
|
172
177
|
"forge.broadcast_player.watching_now": "{count} watching now",
|
|
173
178
|
"forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
|
package/src/types/models.ts
CHANGED
|
@@ -1709,6 +1709,11 @@ export type ILiveEvent = {
|
|
|
1709
1709
|
|
|
1710
1710
|
export type ILiveBroadcast = {
|
|
1711
1711
|
id: string;
|
|
1712
|
+
/**
|
|
1713
|
+
* The v1 realtime path, over Cloudflare Calls. Kept exactly as it is until v1
|
|
1714
|
+
* itself is retired. Not the same thing as `realtime` below, and a broadcast
|
|
1715
|
+
* carries one or the other, never both.
|
|
1716
|
+
*/
|
|
1712
1717
|
realtimeConfig?: {
|
|
1713
1718
|
sessionId: string;
|
|
1714
1719
|
tracks: {
|
|
@@ -1716,6 +1721,19 @@ export type ILiveBroadcast = {
|
|
|
1716
1721
|
trackName: string;
|
|
1717
1722
|
}[];
|
|
1718
1723
|
};
|
|
1724
|
+
/**
|
|
1725
|
+
* Can this broadcast be watched over the platform's own media plane?
|
|
1726
|
+
*
|
|
1727
|
+
* `available` says the live room exists and the feature is on, and it says
|
|
1728
|
+
* nothing about whether THIS viewer will get in: the room has a hard cap and
|
|
1729
|
+
* the token endpoint is the only thing that knows how full it is. So it is a
|
|
1730
|
+
* flag to ATTEMPT the plane on, never a promise, and a player that treats it
|
|
1731
|
+
* as one leaves the hundred-and-first viewer on a black rectangle.
|
|
1732
|
+
*
|
|
1733
|
+
* It deliberately carries no room id and no token. The read that returns it
|
|
1734
|
+
* is unauthenticated, so everything in it is public.
|
|
1735
|
+
*/
|
|
1736
|
+
realtime?: { available: boolean };
|
|
1719
1737
|
events: {
|
|
1720
1738
|
eventId: string;
|
|
1721
1739
|
eventTitle: string;
|
|
@@ -38,8 +38,12 @@ const readSessionId = (broadcastId?: string): string | null => {
|
|
|
38
38
|
* be checked without a browser.
|
|
39
39
|
*/
|
|
40
40
|
export function useBroadcastWatch(broadcastId?: string) {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// The stored pass rides along on both reads: a gated broadcast withholds its
|
|
42
|
+
// playback credentials from a caller who cannot show one, so a read without
|
|
43
|
+
// it comes back playable-looking and empty.
|
|
44
|
+
const storedSessionId = readSessionId(broadcastId) ?? undefined;
|
|
45
|
+
const broadcastQuery = useLiveBroadcast(broadcastId, storedSessionId);
|
|
46
|
+
const pollQuery = useLiveBroadcastPoll(broadcastId, 5000, storedSessionId);
|
|
43
47
|
const validatePass = useValidateBroadcastPass();
|
|
44
48
|
const sessionApi = useBroadcastSessionApi();
|
|
45
49
|
|
|
@@ -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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
439
|
+
* navigates. Public: no bearer token needed.
|
|
325
440
|
*/
|
|
326
441
|
export function useAcceptWorkInvite() {
|
|
327
442
|
const { client } = useForge();
|