@tribe-nest/forge 2.2.0 → 3.2.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.
@@ -0,0 +1,422 @@
1
+ // GENERATED FILE — DO NOT EDIT.
2
+ // Produced from the platform event catalog by:
3
+ // cd apps/backend && npm run generate:forge-events
4
+ //
5
+ // The catalog (apps/backend/src/services/admin/app/eventCatalog.ts) is the single
6
+ // definition of what an app can receive and what shape it arrives in. Editing
7
+ // this file by hand creates a second one, and the copy that drifts is the one
8
+ // nobody is testing.
9
+
10
+ /**
11
+ * Every platform event an app can subscribe to, and the exact body it carries.
12
+ *
13
+ * Delivery is at-least-once — dedupe on `eventId`. The same event WILL arrive
14
+ * again after a retry, and a handler that acts twice will double-send.
15
+ *
16
+ * A body may additionally carry `partial: true`. That means the platform could
17
+ * not build the full payload (the record was missing, or the enriched body did
18
+ * not match its own schema) and sent the identifying ids instead. Re-read what
19
+ * you need through the platform client rather than trusting the missing fields.
20
+ */
21
+ export type PlatformEventMap = {
22
+ /**
23
+ * Affiliate applied — Someone applied to the affiliate program.
24
+ *
25
+ * Requires the `affiliates.read` grant.
26
+ */
27
+ "affiliate.applied": {
28
+ affiliateId: string;
29
+ };
30
+ /**
31
+ * Checkout abandoned — Someone started a checkout and did not finish it.
32
+ *
33
+ * Requires the `products.read` grant.
34
+ */
35
+ "checkout.abandoned": {
36
+ checkoutId: string;
37
+ source: string;
38
+ sourceRecordId?: string | null;
39
+ contactId?: string | null;
40
+ email?: string | null;
41
+ totalCents?: number | null;
42
+ currency?: string | null;
43
+ };
44
+ /**
45
+ * Checkout recovered — An abandoned checkout was completed after all.
46
+ *
47
+ * Requires the `products.read` grant.
48
+ */
49
+ "checkout.recovered": {
50
+ checkoutId: string;
51
+ source: string | null;
52
+ sourceRecordId: string | null;
53
+ contactId: string | null;
54
+ email: string | null;
55
+ totalCents: number | null;
56
+ currency: string | null;
57
+ };
58
+ /**
59
+ * Collection entry submitted — A visitor submitted an entry to a collection.
60
+ *
61
+ * Requires the `collections.read` grant.
62
+ */
63
+ "collection.submission": {
64
+ entryId: string;
65
+ collectionId: string;
66
+ collectionName?: string | null;
67
+ status?: string | null;
68
+ };
69
+ /**
70
+ * Community post created — A member posted in a community space.
71
+ *
72
+ * Requires the `community.new_post` grant.
73
+ */
74
+ "community_post.created": {
75
+ postId: string;
76
+ spaceId: string;
77
+ authorAccountId?: string | null;
78
+ };
79
+ /**
80
+ * Community reply created — A member replied to a community post.
81
+ *
82
+ * Requires the `community.new_comment` grant.
83
+ */
84
+ "community_reply.created": {
85
+ replyId: string;
86
+ postId: string;
87
+ spaceId?: string | null;
88
+ authorAccountId?: string | null;
89
+ parentReplyId?: string | null;
90
+ };
91
+ /**
92
+ * Contact form message — Someone sent a message through a website contact form.
93
+ *
94
+ * Requires the `contacts.read` grant.
95
+ */
96
+ "contact_message.created": {
97
+ senderName?: string | null;
98
+ senderEmail?: string | null;
99
+ message?: string | null;
100
+ };
101
+ /**
102
+ * Form submitted — A form was submitted. Carries the submission id — read the answers through the platform client.
103
+ *
104
+ * Requires the `forms.read` grant.
105
+ */
106
+ "contact.form_submitted": {
107
+ formId: string;
108
+ formTitle: string | null;
109
+ submissionId: string | null;
110
+ contactId: string | null;
111
+ };
112
+ /**
113
+ * Quiz completed — A contact completed a graded form, with their score and outcome.
114
+ *
115
+ * Requires the `contacts.read` grant.
116
+ */
117
+ "contact.quiz_completed": {
118
+ contactId: string | null;
119
+ formId: string;
120
+ submissionId?: string | null;
121
+ quizResultId?: string | null;
122
+ percent?: number | null;
123
+ passed?: boolean | null;
124
+ outcomeKey?: string | null;
125
+ };
126
+ /**
127
+ * Tag added to contact — A tag was applied to a contact.
128
+ *
129
+ * Requires the `contacts.read` grant.
130
+ */
131
+ "contact.tag_added": {
132
+ contactId: string;
133
+ tagId: string;
134
+ };
135
+ /**
136
+ * Course completed — A learner finished a course.
137
+ *
138
+ * Requires the `courses.read` grant.
139
+ */
140
+ "course.completed": {
141
+ courseId: string;
142
+ courseTitle: string | null;
143
+ courseAccessId: string | null;
144
+ contactId: string | null;
145
+ completedAt: string | null;
146
+ };
147
+ /**
148
+ * Lesson progressed — A learner completed a lesson.
149
+ *
150
+ * Requires the `courses.read` grant.
151
+ */
152
+ "course.lesson_progressed": {
153
+ courseId: string;
154
+ courseAccessId?: string | null;
155
+ lessonId?: string | null;
156
+ contactId?: string | null;
157
+ cohortId?: string | null;
158
+ };
159
+ /**
160
+ * Payment disputed — A customer disputed a payment (chargeback).
161
+ *
162
+ * Requires the `income.read` grant.
163
+ */
164
+ "dispute.created": {
165
+ disputeId: string;
166
+ amountCents?: number | null;
167
+ reason?: string | null;
168
+ };
169
+ /**
170
+ * Document signed or decided — A proposal or contract was signed, accepted or declined.
171
+ *
172
+ * Requires the `deals.read` grant.
173
+ */
174
+ "document.completed": {
175
+ documentId: string;
176
+ event: string;
177
+ title?: string | null;
178
+ kind?: string | null;
179
+ };
180
+ /**
181
+ * Email link clicked — A recipient clicked a link in a marketing email.
182
+ *
183
+ * Requires the `emails.read` grant.
184
+ */
185
+ "email.clicked": {
186
+ emailId: string;
187
+ contactId?: string | null;
188
+ linkUrl?: string | null;
189
+ occurredAt?: string | null;
190
+ };
191
+ /**
192
+ * Email opened — A recipient opened a marketing email.
193
+ *
194
+ * Requires the `emails.read` grant.
195
+ */
196
+ "email.opened": {
197
+ emailId: string;
198
+ contactId?: string | null;
199
+ occurredAt?: string | null;
200
+ };
201
+ /**
202
+ * Event tickets bought — Someone paid for tickets to an event.
203
+ *
204
+ * Requires the `events.read` grant.
205
+ */
206
+ "event_ticket_order.paid": {
207
+ orderId: string;
208
+ eventId: string | null;
209
+ status: string | null;
210
+ total: number | null;
211
+ currency: string | null;
212
+ buyer: {
213
+ contactId: string | null;
214
+ email: string | null;
215
+ name: string | null;
216
+ };
217
+ tickets: Array<{
218
+ ticketId: string | null;
219
+ quantity: number;
220
+ unitPrice: number | null;
221
+ }>;
222
+ };
223
+ /**
224
+ * Membership tier archived — A membership tier was archived and can no longer be joined.
225
+ *
226
+ * Requires the `membership.tier_archived` grant.
227
+ */
228
+ "membership_tier.archived": {
229
+ membershipTierId: string;
230
+ name: string | null;
231
+ archived: boolean;
232
+ };
233
+ /**
234
+ * Membership tier created — A new membership tier was published.
235
+ *
236
+ * Requires the `membership.tier_created` grant.
237
+ */
238
+ "membership_tier.created": {
239
+ membershipTierId: string;
240
+ name: string | null;
241
+ priceMonthly: number | null;
242
+ priceYearly: number | null;
243
+ isDefault: boolean;
244
+ archived: boolean;
245
+ };
246
+ /**
247
+ * Inbound message — An SMS or WhatsApp message arrived from a contact.
248
+ *
249
+ * Requires the `contacts.read` grant.
250
+ */
251
+ "message.received": {
252
+ channel: string;
253
+ inboundId: string;
254
+ contactId?: string | null;
255
+ fromPhone?: string | null;
256
+ body?: string | null;
257
+ occurredAt?: string | null;
258
+ };
259
+ /**
260
+ * Content reported — A member reported community content for moderation.
261
+ *
262
+ * Requires the `community.content_reported` grant.
263
+ */
264
+ "moderation.report_created": {
265
+ reportId: string;
266
+ entityId?: string | null;
267
+ entityType?: string | null;
268
+ };
269
+ /**
270
+ * Order paid — A store order was paid for. Carries the order, its line items and a buyer reference.
271
+ *
272
+ * Requires the `products.read` grant.
273
+ */
274
+ "order.paid": {
275
+ orderId: string;
276
+ status: string | null;
277
+ total: number | null;
278
+ currency: string | null;
279
+ placedAt: string | null;
280
+ buyer: {
281
+ contactId: string | null;
282
+ email: string | null;
283
+ name: string | null;
284
+ };
285
+ items: Array<{
286
+ productId: string | null;
287
+ variantId: string | null;
288
+ title: string | null;
289
+ quantity: number;
290
+ unitPrice: number | null;
291
+ }>;
292
+ };
293
+ /**
294
+ * Payout settled — A payout to the creator's bank account settled.
295
+ *
296
+ * Requires the `income.read` grant.
297
+ */
298
+ "payout.settled": {
299
+ payoutId: string | null;
300
+ status: string;
301
+ amountCents: number;
302
+ currency: string;
303
+ };
304
+ /**
305
+ * Member post published — A post was published to the member feed, with the tiers that can see it.
306
+ *
307
+ * Requires the `membership.new_post` grant.
308
+ */
309
+ "post.created": {
310
+ postId: string;
311
+ membershipTiers?: Array<unknown> | null;
312
+ };
313
+ /**
314
+ * Refund failed — A refund could not be completed.
315
+ *
316
+ * Requires the `income.read` grant.
317
+ */
318
+ "refund.failed": {
319
+ refundId: string;
320
+ amountCents: number;
321
+ currency?: string | null;
322
+ reason?: string | null;
323
+ sourceType?: string | null;
324
+ sourceId?: string | null;
325
+ };
326
+ /**
327
+ * Refund succeeded — A refund completed.
328
+ *
329
+ * Requires the `income.read` grant.
330
+ */
331
+ "refund.succeeded": {
332
+ refundId: string;
333
+ sourceType?: string | null;
334
+ sourceId?: string | null;
335
+ amountCents: number;
336
+ capturedCents?: number | null;
337
+ };
338
+ /**
339
+ * Review submitted — A customer left a review, with its rating and moderation status.
340
+ *
341
+ * Requires the `reviews.read` grant.
342
+ */
343
+ "review_submitted": {
344
+ reviewId: string;
345
+ entityType: string;
346
+ entityId: string;
347
+ contactId?: string | null;
348
+ rating?: number | null;
349
+ status?: string | null;
350
+ isEdit?: boolean | null;
351
+ };
352
+ /**
353
+ * Review published — A review passed moderation and is now public.
354
+ *
355
+ * Requires the `reviews.read` grant.
356
+ */
357
+ "review.published": {
358
+ reviewId: string;
359
+ };
360
+ /**
361
+ * Shipment status changed — A shipment for an order moved — dispatched, in transit, delivered.
362
+ *
363
+ * Requires the `products.read` grant.
364
+ */
365
+ "shipping.status_updated": {
366
+ orderId: string;
367
+ deliveryGroupId?: string | null;
368
+ shippingLabelId?: string | null;
369
+ status?: string | null;
370
+ trackingNumber?: string | null;
371
+ carrier?: string | null;
372
+ };
373
+ /**
374
+ * Website published — A website was published.
375
+ *
376
+ * Requires the `websites.read` grant.
377
+ */
378
+ "website.published": Record<string, unknown>;
379
+ };
380
+
381
+ /** The name of any event an app can receive. */
382
+ export type PlatformEventName = keyof PlatformEventMap;
383
+
384
+ /** The body carried by one named event. */
385
+ export type PlatformEventData<E extends PlatformEventName> = PlatformEventMap[E] & { partial?: boolean };
386
+
387
+ /** Which grant unlocks each event, for error messages and docs. */
388
+ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
389
+ "affiliate.applied": "affiliates.read",
390
+ "checkout.abandoned": "products.read",
391
+ "checkout.recovered": "products.read",
392
+ "collection.submission": "collections.read",
393
+ "community_post.created": "community.new_post",
394
+ "community_reply.created": "community.new_comment",
395
+ "contact_message.created": "contacts.read",
396
+ "contact.form_submitted": "forms.read",
397
+ "contact.quiz_completed": "contacts.read",
398
+ "contact.tag_added": "contacts.read",
399
+ "course.completed": "courses.read",
400
+ "course.lesson_progressed": "courses.read",
401
+ "dispute.created": "income.read",
402
+ "document.completed": "deals.read",
403
+ "email.clicked": "emails.read",
404
+ "email.opened": "emails.read",
405
+ "event_ticket_order.paid": "events.read",
406
+ "membership_tier.archived": "membership.tier_archived",
407
+ "membership_tier.created": "membership.tier_created",
408
+ "message.received": "contacts.read",
409
+ "moderation.report_created": "community.content_reported",
410
+ "order.paid": "products.read",
411
+ "payout.settled": "income.read",
412
+ "post.created": "membership.new_post",
413
+ "refund.failed": "income.read",
414
+ "refund.succeeded": "income.read",
415
+ "review_submitted": "reviews.read",
416
+ "review.published": "reviews.read",
417
+ "shipping.status_updated": "products.read",
418
+ "website.published": "websites.read",
419
+ };
420
+
421
+ /** Every event name, in one array — handy for building a UI or validating input. */
422
+ export const PLATFORM_EVENT_NAMES = Object.keys(PLATFORM_EVENT_PERMISSIONS) as PlatformEventName[];
@@ -265,9 +265,40 @@ export interface PublicAuthState {
265
265
  } | null;
266
266
  }
267
267
 
268
+ /**
269
+ * What `register` returns: the platform emailed a 6-digit code and issued NO
270
+ * session. Collect the code and call `verifyRegistration` — there is no variant
271
+ * of signup that logs a fan straight in.
272
+ */
273
+ export type PublicAuthChallenge = { status: "verify_email"; email: string };
274
+
268
275
  export interface PublicAuthContextType extends PublicAuthState {
276
+ /**
277
+ * @deprecated Broken — do not use. Fan login is a two-step code flow, so
278
+ * `POST /public/sessions` returns `{ message, email }` and never a token; this
279
+ * method still expects one and will store `undefined`. Use
280
+ * `validateLoginCredentials` (emails the code) then `verifyLoginCode`.
281
+ */
269
282
  login: (data: LoginInput & { profileId: string }) => Promise<{ smartLinkPath: string | undefined }>;
270
- register: (data: PublicCreateAccountInput) => Promise<{ smartLinkPath: string | undefined }>;
283
+ /**
284
+ * Step 1 of 2. Creates the account and emails a code; issues no session.
285
+ * Returns the same challenge whether or not the address is already known to the
286
+ * platform, so the response can't be used to discover registered emails.
287
+ */
288
+ register: (data: PublicCreateAccountInput) => Promise<PublicAuthChallenge>;
289
+ /**
290
+ * Step 2 of 2. Redeems the emailed code, signs the fan in, and applies what the
291
+ * signup earned (member identity, a membership from `couponCode`, consent) —
292
+ * which is why the coupon/consent flags are repeated here.
293
+ */
294
+ verifyRegistration: (data: {
295
+ email: string;
296
+ code: string;
297
+ profileId?: string;
298
+ couponCode?: string;
299
+ acceptedTerms?: boolean;
300
+ acceptedPrivacy?: boolean;
301
+ }) => Promise<{ smartLinkPath: string | undefined }>;
271
302
  validateLoginCredentials: (data: LoginInput & { profileId: string }) => Promise<string>;
272
303
  verifyLoginCode: (email: string, code: string, profileId: string) => Promise<{ smartLinkPath: string | undefined }>;
273
304
  resendLoginCode: (email: string) => Promise<void>;
@@ -385,8 +416,18 @@ export type CollectionSearchResult = PaginatedData<CollectionEntry> & {
385
416
 
386
417
  // A leaf { field, op, value } or an and/or group — the structured filter tree.
387
418
  export type CollectionFilterOp =
388
- | "eq" | "ne" | "gt" | "gte" | "lt" | "lte"
389
- | "in" | "between" | "contains" | "startsWith" | "arrayContains" | "isNull";
419
+ | "eq"
420
+ | "ne"
421
+ | "gt"
422
+ | "gte"
423
+ | "lt"
424
+ | "lte"
425
+ | "in"
426
+ | "between"
427
+ | "contains"
428
+ | "startsWith"
429
+ | "arrayContains"
430
+ | "isNull";
390
431
  export type CollectionFilterNode =
391
432
  | { and: CollectionFilterNode[] }
392
433
  | { or: CollectionFilterNode[] }
@@ -13,10 +13,17 @@ export interface UseSignupFormOptions {
13
13
  requireAcceptance?: boolean;
14
14
  }
15
15
 
16
- /** Headless account signup. Bring your own UI (or use the styled <SignupForm>). */
16
+ /**
17
+ * Headless account signup. Bring your own UI (or use the styled <SignupForm>).
18
+ *
19
+ * TWO STEPS: `submit()` creates the account and the platform emails a 6-digit
20
+ * code — no session yet. `awaitingCode` then flips true; collect the code into
21
+ * `code` and call `submitCode()`, which signs the fan in and fires `onSuccess`.
22
+ * Render the code input whenever `awaitingCode` is true.
23
+ */
17
24
  export function useSignupForm(opts: UseSignupFormOptions = {}) {
18
25
  const profileId = useForge().profileId ?? "";
19
- const { register } = usePublicAuth();
26
+ const { register, verifyRegistration } = usePublicAuth();
20
27
 
21
28
  const [firstName, setFirstName] = useState("");
22
29
  const [lastName, setLastName] = useState("");
@@ -25,6 +32,9 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
25
32
  const [acceptedTerms, setAcceptedTerms] = useState(false);
26
33
  const [error, setError] = useState<string | null>(null);
27
34
  const [isSubmitting, setIsSubmitting] = useState(false);
35
+ // Step 2: set once the code has been emailed.
36
+ const [awaitingCode, setAwaitingCode] = useState(false);
37
+ const [code, setCode] = useState("");
28
38
 
29
39
  const requireAcceptance = opts.requireAcceptance ?? true;
30
40
 
@@ -48,7 +58,7 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
48
58
  }
49
59
  setIsSubmitting(true);
50
60
  try {
51
- const { smartLinkPath } = await register({
61
+ await register({
52
62
  email: email.trim(),
53
63
  password,
54
64
  firstName: firstName.trim(),
@@ -59,7 +69,8 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
59
69
  acceptedTerms: true,
60
70
  acceptedPrivacy: true,
61
71
  });
62
- opts.onSuccess?.(smartLinkPath);
72
+ // No session yet — the emailed code is the next step.
73
+ setAwaitingCode(true);
63
74
  } catch (e) {
64
75
  setError(msg(e) || "Could not create your account.");
65
76
  } finally {
@@ -67,6 +78,55 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
67
78
  }
68
79
  };
69
80
 
81
+ /** Step 2: redeem the emailed code. Signs the fan in and fires `onSuccess`. */
82
+ const submitCode = async () => {
83
+ setError(null);
84
+ if (!/^\d{6}$/.test(code.trim())) {
85
+ setError("Enter the 6-digit code we emailed you.");
86
+ return;
87
+ }
88
+ setIsSubmitting(true);
89
+ try {
90
+ const { smartLinkPath } = await verifyRegistration({
91
+ email: email.trim(),
92
+ code: code.trim(),
93
+ profileId,
94
+ // Repeated here because this is the request that actually applies them.
95
+ couponCode: opts.couponCode,
96
+ acceptedTerms: true,
97
+ acceptedPrivacy: true,
98
+ });
99
+ opts.onSuccess?.(smartLinkPath);
100
+ } catch (e) {
101
+ setError(msg(e) || "That code did not work.");
102
+ } finally {
103
+ setIsSubmitting(false);
104
+ }
105
+ };
106
+
107
+ /** Re-send a code (throttled to one per 60s server-side). */
108
+ const resendCode = async () => {
109
+ setError(null);
110
+ setIsSubmitting(true);
111
+ try {
112
+ await register({
113
+ email: email.trim(),
114
+ password,
115
+ firstName: firstName.trim(),
116
+ lastName: lastName.trim(),
117
+ profileId,
118
+ membershipTierId: opts.membershipTierId,
119
+ couponCode: opts.couponCode,
120
+ acceptedTerms: true,
121
+ acceptedPrivacy: true,
122
+ });
123
+ } catch (e) {
124
+ setError(msg(e) || "Could not resend the code.");
125
+ } finally {
126
+ setIsSubmitting(false);
127
+ }
128
+ };
129
+
70
130
  return {
71
131
  firstName,
72
132
  setFirstName,
@@ -81,5 +141,10 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
81
141
  error,
82
142
  isSubmitting,
83
143
  submit,
144
+ awaitingCode,
145
+ code,
146
+ setCode,
147
+ submitCode,
148
+ resendCode,
84
149
  };
85
150
  }
@@ -1,9 +1,4 @@
1
- import {
2
- useMutation,
3
- useQuery,
4
- useQueryClient,
5
- type UseQueryResult,
6
- } from "@tanstack/react-query";
1
+ import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
7
2
  import { useForge } from "../../../provider/ForgeProvider";
8
3
 
9
4
  /**
@@ -113,10 +108,24 @@ export interface WorkInviteContext {
113
108
  profileName: string;
114
109
  email: string;
115
110
  firstName: string | null;
111
+ /**
112
+ * 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
114
+ * form (C10).
115
+ */
116
+ requiresExistingLogin?: boolean;
116
117
  }
117
118
 
118
119
  export interface WorkInviteAcceptResult {
119
- token: string;
120
+ /**
121
+ * True when the invited address already has an account: the invite granted
122
+ * portal access but issued NO session, and the person signs in normally
123
+ * (C10). `token` is absent in that case.
124
+ */
125
+ requiresLogin?: boolean;
126
+ token?: string;
127
+ /** Rotating refresh token for the portal session (backend §7) — persist it. */
128
+ refreshToken?: string;
120
129
  projectId: string;
121
130
  account: { id: string; email: string; firstName: string | null; lastName: string | null };
122
131
  }
@@ -125,8 +134,7 @@ export interface WorkInviteAcceptResult {
125
134
 
126
135
  const keys = {
127
136
  projects: (profileId?: string) => ["work-portal-projects", profileId] as const,
128
- report: (profileId: string | undefined, projectId: string) =>
129
- ["work-portal-report", profileId, projectId] as const,
137
+ report: (profileId: string | undefined, projectId: string) => ["work-portal-report", profileId, projectId] as const,
130
138
  tokenReport: (token: string) => ["work-portal-token-report", token] as const,
131
139
  taskComments: (profileId: string | undefined, projectId: string, taskId: string) =>
132
140
  ["work-portal-task-comments", profileId, projectId, taskId] as const,
@@ -212,10 +220,9 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
212
220
  const query = useQuery<WorkTaskComment[]>({
213
221
  queryKey: keys.taskComments(profileId, projectId, taskId),
214
222
  queryFn: async () => {
215
- const res = await client.get(
216
- `/public/work/projects/${projectId}/tasks/${taskId}/comments`,
217
- { params: { profileId } },
218
- );
223
+ const res = await client.get(`/public/work/projects/${projectId}/tasks/${taskId}/comments`, {
224
+ params: { profileId },
225
+ });
219
226
  return res.data as WorkTaskComment[];
220
227
  },
221
228
  enabled: !!profileId && !!client && !!projectId && !!taskId,
@@ -255,18 +262,14 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
255
262
  * projects — GET /public/work/projects/:projectId/tasks/:taskId/attachments.
256
263
  * Tier-1 primitive: returns clean data, renders nothing.
257
264
  */
258
- export function useWorkTaskAttachments(
259
- projectId: string,
260
- taskId: string,
261
- ): UseQueryResult<WorkTaskAttachment[]> {
265
+ export function useWorkTaskAttachments(projectId: string, taskId: string): UseQueryResult<WorkTaskAttachment[]> {
262
266
  const { client, profileId } = useForge();
263
267
  return useQuery<WorkTaskAttachment[]>({
264
268
  queryKey: keys.taskAttachments(profileId, projectId, taskId),
265
269
  queryFn: async () => {
266
- const res = await client.get(
267
- `/public/work/projects/${projectId}/tasks/${taskId}/attachments`,
268
- { params: { profileId } },
269
- );
270
+ const res = await client.get(`/public/work/projects/${projectId}/tasks/${taskId}/attachments`, {
271
+ params: { profileId },
272
+ });
270
273
  return res.data as WorkTaskAttachment[];
271
274
  },
272
275
  enabled: !!profileId && !!client && !!projectId && !!taskId,