@loopwise/sdk 0.1.0-beta.4 → 0.1.0-beta.5

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/CHANGELOG.md CHANGED
@@ -9,6 +9,45 @@ follow the same rules as a single 0.x line.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.1.0-beta.5] — 2026-10-15
13
+
14
+ Member actions on a Loopwise Page. A customer wanted signed-in members to
15
+ rate a course from their own page UI; that worked only through hand-written
16
+ GraphQL with several silent traps (a cached call reads everyone as signed
17
+ out, an omitted comment wipes the stored text). This release wraps it:
18
+ `auth`, `courses.reviewState` and `reviews` (TEACH-24177, #17705, #17711).
19
+ The server now also refuses ratings on courses with ratings off
20
+ (TEACH-24153) and scores outside 0.5–5 in half steps (TEACH-24176, #17704).
21
+
22
+ ### Added
23
+
24
+ - `loopwise.auth.getMe()` / `isAuthenticated()` — the member signed in to
25
+ the site, read from the page's own sign-in cookie. (TEACH-24177,
26
+ covers the core of TEACH-18410)
27
+ - `loopwise.courses.reviewState({ slug })` — one request returning the
28
+ member's review status for a course (`ratings_off`, `signed_out`,
29
+ `not_purchased`, `can_review`, `reviewed`), their review, the rating
30
+ summary and a `loginUrl` back to the page. (TEACH-24177)
31
+ - `loopwise.reviews.submit({ courseId, score, comment })` and
32
+ `loopwise.reviews.delete({ courseId, reviewId })`. `submit` checks the
33
+ score (0.5–5 in steps of 0.5) before sending. `comment` is required
34
+ and replaces the stored text (`""` clears it); leaving it out rejects
35
+ with `CONFIG` instead of clearing the text by accident.
36
+ (TEACH-24177)
37
+ - `LoopwiseError` codes `AUTH_REQUIRED`, `NOT_SIGNED_IN`,
38
+ `NOT_PURCHASED`, `RATINGS_OFF`, `INVALID_SCORE`. Member methods reject
39
+ with `AUTH_REQUIRED` server-side and off-origin (a `*.loopwise.page`
40
+ preview) instead of answering as a signed-out visitor, and never use
41
+ the cached `GET` path. `GraphQLError` gains the server's `code`; it and
42
+ `LoopwiseErrorCode` are now exported types. (TEACH-24177)
43
+ - New exported types: `Member`, `MemberReview`, `CourseRating`,
44
+ `CourseReviewState`, `CourseReviewStatus`. (TEACH-24177)
45
+
46
+ ### Security
47
+
48
+ - Storefront `Meeting.roomUrl` is empty unless the viewer is an attendee,
49
+ school staff, or a school-bound admin credential. (TEACH-22983)
50
+
12
51
  ## [0.1.0-beta.4] — 2026-09-10
13
52
 
14
53
  Storefront schema pinning for browser clients. A design partner could
@@ -100,7 +139,8 @@ Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
100
139
  `batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
101
140
  explicit cross-origin endpoint resolution.
102
141
 
103
- [Unreleased]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.4...HEAD
142
+ [Unreleased]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.5...HEAD
143
+ [0.1.0-beta.5]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.4...@loopwise/sdk@0.1.0-beta.5
104
144
  [0.1.0-beta.4]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.3...@loopwise/sdk@0.1.0-beta.4
105
145
  [0.1.0-beta.3]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.2...@loopwise/sdk@0.1.0-beta.3
106
146
  [0.1.0-beta.2]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.1...@loopwise/sdk@0.1.0-beta.2
package/README.md CHANGED
@@ -59,8 +59,9 @@ right API origin.
59
59
  ### Limits when not on the site itself
60
60
 
61
61
  Cross-origin requests **do not carry first-party cookies**. So calls
62
- that require a signed-in visitor — `cart`, `viewer.profile`,
63
- authenticated mutations — **will not work** from off-site code.
62
+ that require a signed-in visitor — `auth.getMe`, `courses.reviewState`,
63
+ `reviews.*`, hand-written authenticated queries — **will not work** from
64
+ off-site code; the SDK's member methods reject with `AUTH_REQUIRED`.
64
65
  Public, anonymous reads (`courses.list`, `posts.list`, etc.) work fine.
65
66
 
66
67
  The site's `/api/graphql` answers the browser's CORS preflight with a
@@ -85,6 +86,108 @@ const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
85
86
  For preview UX that needs an authenticated viewer, see
86
87
  [Preview modes](#preview-modes) below.
87
88
 
89
+ ## Member actions on a Loopwise Page
90
+
91
+ A Loopwise Page served from the site's own domain (`acme.kaik.io/lw/...`,
92
+ `nii.school/reviews`) can act for the member who is signed in to that
93
+ site: read who they are, show their review state for a course, and save
94
+ or delete their review. You write no GraphQL.
95
+
96
+ ```ts
97
+ import { createClient, LoopwiseError } from '@loopwise/sdk';
98
+
99
+ const loopwise = createClient({});
100
+
101
+ const me = await loopwise.auth.getMe(); // { id, name } or null
102
+
103
+ let state = await loopwise.courses.reviewState({ slug: 'finance' });
104
+ // state.status: 'ratings_off' | 'signed_out' | 'not_purchased' | 'can_review' | 'reviewed'
105
+ // state.courseId, state.review ({ id, score, comment } | null),
106
+ // state.rating ({ average, total } | null), state.loginUrl
107
+
108
+ // Re-read after every write so the page (and the next click) never acts
109
+ // on a stale review.
110
+ const refresh = async () => {
111
+ state = await loopwise.courses.reviewState({ slug: 'finance' });
112
+ render(state);
113
+ };
114
+
115
+ const onError = (e) => {
116
+ if (e instanceof LoopwiseError && e.code === 'NOT_SIGNED_IN') {
117
+ location.href = state.loginUrl;
118
+ } else {
119
+ showError(e); // INVALID_SCORE, RATINGS_OFF, NOT_PURCHASED, …
120
+ }
121
+ };
122
+
123
+ form.addEventListener('submit', async (event) => {
124
+ event.preventDefault();
125
+ submitButton.disabled = true; // one email per click, not per double-click
126
+ try {
127
+ await loopwise.reviews.submit({
128
+ courseId: state.courseId,
129
+ score: Number(form.score.value), // 0.5, 1, 1.5 … 5
130
+ comment: form.comment.value, // required; '' clears it
131
+ });
132
+ await refresh();
133
+ } catch (e) {
134
+ onError(e);
135
+ } finally {
136
+ submitButton.disabled = false;
137
+ }
138
+ });
139
+
140
+ deleteButton.addEventListener('click', async () => {
141
+ if (!state.review) return;
142
+ deleteButton.disabled = true;
143
+ try {
144
+ await loopwise.reviews.delete({ courseId: state.courseId, reviewId: state.review.id });
145
+ await refresh();
146
+ } catch (e) {
147
+ onError(e);
148
+ } finally {
149
+ deleteButton.disabled = false;
150
+ }
151
+ });
152
+ ```
153
+
154
+ The rules these methods enforce, and why:
155
+
156
+ - **Same origin only.** The member is known from the site's sign-in
157
+ cookie, which the browser sends only to the page's own origin. Server
158
+ code, SSR, and a `*.loopwise.page` preview have no member. There every
159
+ member method rejects with `LoopwiseError` code `'AUTH_REQUIRED'` and
160
+ sends nothing, instead of reporting everyone as signed out.
161
+ - **Fetch member state in the browser, after load.** Loopwise Page HTML is
162
+ cached at the CDN and shared by every visitor, so never render member
163
+ state into the HTML. Render a neutral shell and fill it in from
164
+ `reviewState()` on the client.
165
+ - **No `cache` on member calls.** A cached call is a cookie-less `GET`,
166
+ which would read every member as signed out. Member methods take no
167
+ options and always `POST`.
168
+ - **Submit on an explicit action.** Each `reviews.submit` emails the site
169
+ owner. Call it from a Submit button, never from autosave or on every
170
+ star change.
171
+ - **One review per member per course.** `submit` creates it the first time
172
+ and replaces it after that; `comment` replaces the stored text too, so
173
+ pass the existing text to keep it.
174
+
175
+ `reviewState` checks in this order: `ratings_off` (reviews are off for the
176
+ course; hide the UI), `signed_out` (link to `loginUrl`, which returns to
177
+ this page after sign-in), `reviewed` (the member's review is in
178
+ `review`), `not_purchased` (signed in but not enrolled), `can_review`.
179
+
180
+ | `LoopwiseError.code` | When |
181
+ |---|---|
182
+ | `AUTH_REQUIRED` | Called server-side or off-origin. Nothing was sent. |
183
+ | `INVALID_SCORE` | `score` is not 0.5–5 in steps of 0.5. Checked before sending. |
184
+ | `NOT_SIGNED_IN` | No member is signed in. Send them to `loginUrl`. |
185
+ | `NOT_PURCHASED` | The member is not enrolled in the course. |
186
+ | `RATINGS_OFF` | The site turned reviews off for the course. |
187
+ | `CONFIG` | A required argument (`slug`, `courseId`, `reviewId`, `comment`) is missing. Nothing was sent. |
188
+ | `AUTH`, `RATE_LIMIT`, `NETWORK` | Transport failures (HTTP 401/403, 429, other), the same as for every call. They are never reported as a signed-out member. |
189
+ | `GRAPHQL` | Anything else, such as an unknown slug or another member's review; the server's errors are on `graphqlErrors`. |
190
+
88
191
  ## `cache` option
89
192
 
90
193
  Read-only resource calls accept an opt-in `cache` option that flips the
@@ -204,6 +307,9 @@ your own state (`useEffect` + `useState`, TanStack Query, etc.).
204
307
  - `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
205
308
  - `loopwise.posts.list(args, options?)` / `.get(args, options?)`.
206
309
  - `loopwise.orders.get(args, options?)` — post-checkout pixel-tracking pattern; see the method's JSDoc for the `?tradeNo=…` → `fbq('track', 'Purchase', …)` recipe.
310
+ - `loopwise.auth.getMe()` / `.isAuthenticated()` — the signed-in member; see [Member actions on a Loopwise Page](#member-actions-on-a-loopwise-page).
311
+ - `loopwise.courses.reviewState({ slug })` — the member's review status for a course.
312
+ - `loopwise.reviews.submit({ courseId, score, comment })` / `.delete({ courseId, reviewId })`.
207
313
  - `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
208
314
  - `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
209
315
 
package/dist/index.d.mts CHANGED
@@ -1,8 +1,27 @@
1
1
  import { LoopwiseConfig } from "./config.mjs";
2
2
  //#region src/error.d.ts
3
- type LoopwiseErrorCode = "NETWORK" | "AUTH" | "GRAPHQL" | "RATE_LIMIT" | "CONFIG";
3
+ /**
4
+ * What went wrong, as a stable string to branch on.
5
+ *
6
+ * - `NETWORK`, `AUTH`, `RATE_LIMIT`: transport failures.
7
+ * - `GRAPHQL`: the server answered with an error not mapped below; the raw
8
+ * errors are on `LoopwiseError.graphqlErrors`.
9
+ * - `CONFIG`: the client or a call was set up wrong; nothing was sent.
10
+ * - `AUTH_REQUIRED`: a member action (`auth.*`, `courses.reviewState`,
11
+ * `reviews.*`) ran where no member can be signed in — server-side, or in a
12
+ * browser whose page is on another origin than the API (a
13
+ * `*.loopwise.page` preview). Nothing was sent.
14
+ * - `NOT_SIGNED_IN`: a review needs a signed-in member and there is none.
15
+ * - `NOT_PURCHASED`: the member is signed in but not enrolled in the course.
16
+ * - `RATINGS_OFF`: the site turned reviews off for the course.
17
+ * - `INVALID_SCORE`: a review score outside 0.5 to 5 in half-star steps.
18
+ * Checked before sending, and mapped from the server's `Rate-001`.
19
+ */
20
+ type LoopwiseErrorCode = "NETWORK" | "AUTH" | "GRAPHQL" | "RATE_LIMIT" | "CONFIG" | "AUTH_REQUIRED" | "NOT_SIGNED_IN" | "NOT_PURCHASED" | "RATINGS_OFF" | "INVALID_SCORE";
4
21
  interface GraphQLError {
5
22
  message: string;
23
+ /** Storefront service error code, such as `User-000` or `Rate-002`. */
24
+ code?: string;
6
25
  path?: (string | number)[];
7
26
  extensions?: Record<string, unknown>;
8
27
  }
@@ -82,6 +101,15 @@ declare class QueryBuilder<T> implements PromiseLike<T> {
82
101
  }
83
102
  //#endregion
84
103
  //#region src/client.d.ts
104
+ interface GraphQLPayload {
105
+ query: string;
106
+ variables?: Record<string, unknown>;
107
+ operationName?: string;
108
+ }
109
+ interface GraphQLResponse {
110
+ data?: Record<string, unknown> | null;
111
+ errors?: GraphQLError[];
112
+ }
85
113
  declare class LoopwiseClient implements BatchedFetcher {
86
114
  readonly config: LoopwiseConfig;
87
115
  private endpoint;
@@ -97,7 +125,16 @@ declare class LoopwiseClient implements BatchedFetcher {
97
125
  batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
98
126
  descriptor: QueryDescriptor;
99
127
  }; }, options?: FetchOptions): Promise<R>;
128
+ /**
129
+ * POST a document on behalf of the signed-in member and return the whole
130
+ * GraphQL response, errors included, so the caller can map service error
131
+ * codes. Refuses wherever the member's `token` cookie cannot ride along.
132
+ */
133
+ member(payload: GraphQLPayload): Promise<GraphQLResponse>;
134
+ graphqlError(errors: GraphQLError[], code?: LoopwiseErrorCode, message?: string): LoopwiseError;
135
+ private assertMemberOrigin;
100
136
  private fetchGraphQL;
137
+ private request;
101
138
  private shouldUseGet;
102
139
  private buildGetUrl;
103
140
  private resolveApiKey;
@@ -160,6 +197,56 @@ interface ListResult<T> {
160
197
  items: T[];
161
198
  pageInfo: PageInfo;
162
199
  }
200
+ /** The member signed in to this site. */
201
+ interface Member {
202
+ id: string;
203
+ /** Display name; `null` when the member never set one. */
204
+ name: string | null;
205
+ }
206
+ /** A member's own review of a course. One per member per course. */
207
+ interface MemberReview {
208
+ id: string;
209
+ /** 0.5 to 5 in half-star steps. */
210
+ score: number;
211
+ /** Review text; `""` when the member left none. */
212
+ comment: string;
213
+ }
214
+ /** A course's public rating summary. */
215
+ interface CourseRating {
216
+ /** Mean score across visible reviews. */
217
+ average: number;
218
+ /** Number of visible reviews. */
219
+ total: number;
220
+ }
221
+ /**
222
+ * What the visitor can do about reviewing a course, checked in this order:
223
+ *
224
+ * - `ratings_off`: the site turned reviews off for this course. Hide the UI.
225
+ * - `signed_out`: no member is signed in. Link to `loginUrl`.
226
+ * - `reviewed`: the member already reviewed it; `review` holds their score
227
+ * and comment. Submitting again replaces it.
228
+ * - `not_purchased`: signed in, but not enrolled. Only enrolled members can
229
+ * review.
230
+ * - `can_review`: enrolled and not reviewed yet.
231
+ */
232
+ type CourseReviewStatus = "ratings_off" | "signed_out" | "not_purchased" | "can_review" | "reviewed";
233
+ interface CourseReviewState {
234
+ status: CourseReviewStatus;
235
+ /**
236
+ * Pass to `reviews.submit({ courseId })`, and with `review.id` (when
237
+ * `review` is not `null`) to `reviews.delete({ courseId, reviewId })`.
238
+ */
239
+ courseId: string;
240
+ /** The signed-in member's own review, or `null` when they have none. */
241
+ review: MemberReview | null;
242
+ /** The course's rating summary; `null` when ratings are off. */
243
+ rating: CourseRating | null;
244
+ /**
245
+ * The site's default sign-in page, returning to the current page after
246
+ * sign-in: `/login?redirect=<path>`.
247
+ */
248
+ loginUrl: string;
249
+ }
163
250
  /** Simplified order status. Mirrors the storefront `Order.status` enum. */
164
251
  type OrderStatus = "PAID" | "PENDING" | "FAILED" | "REFUNDED";
165
252
  interface OrderItem {
@@ -185,6 +272,27 @@ interface Order {
185
272
  createdAt: number;
186
273
  }
187
274
  //#endregion
275
+ //#region src/resources/auth.d.ts
276
+ /**
277
+ * The member signed in to this site, read from the page's own sign-in
278
+ * cookie. Works only in the browser on the site's own domain; elsewhere
279
+ * every method rejects with `LoopwiseError` code `'AUTH_REQUIRED'` instead
280
+ * of reporting a signed-out visitor.
281
+ */
282
+ declare class AuthResource {
283
+ private client;
284
+ constructor(client: LoopwiseClient);
285
+ /**
286
+ * The signed-in member, or `null` for a signed-out visitor.
287
+ * @example
288
+ * const me = await loopwise.auth.getMe()
289
+ * greeting.textContent = me ? `Hi ${me.name ?? ''}` : 'Sign in'
290
+ */
291
+ getMe(): Promise<Member | null>;
292
+ /** Whether a member is signed in to this site. */
293
+ isAuthenticated(): Promise<boolean>;
294
+ }
295
+ //#endregion
188
296
  //#region src/resources/courses.d.ts
189
297
  declare const DEFAULT_LIST_FIELDS$1: Selection<Course>;
190
298
  declare const DEFAULT_GET_FIELDS$2: Selection<Course>;
@@ -230,6 +338,30 @@ declare class CoursesResource {
230
338
  * }
231
339
  */
232
340
  get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS$2>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S>>;
341
+ /**
342
+ * What the signed-in member can do about reviewing a course, in one
343
+ * request: whether reviews are on, whether they are signed in and
344
+ * enrolled, their own review, and the course's rating summary.
345
+ *
346
+ * Member state: call it in the browser on the site's own domain, after
347
+ * the page loads. It never uses the `cache` option, and it rejects with
348
+ * `LoopwiseError` code `AUTH_REQUIRED` server-side or off-origin rather
349
+ * than report every visitor as signed out. An unknown slug rejects with
350
+ * `GRAPHQL`.
351
+ *
352
+ * @example
353
+ * const state = await loopwise.courses.reviewState({ slug: 'finance' })
354
+ * switch (state.status) {
355
+ * case 'ratings_off': return hide()
356
+ * case 'signed_out': return showLink(state.loginUrl)
357
+ * case 'not_purchased': return showText('Enroll to review this course')
358
+ * case 'can_review': return showForm()
359
+ * case 'reviewed': return showForm(state.review)
360
+ * }
361
+ */
362
+ reviewState(args: {
363
+ slug: string;
364
+ }): Promise<CourseReviewState>;
233
365
  }
234
366
  //#endregion
235
367
  //#region src/resources/orders.d.ts
@@ -318,11 +450,78 @@ declare class PostsResource {
318
450
  get<S extends Selection<Post> = typeof DEFAULT_GET_FIELDS>(args: PostGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Post, S>>;
319
451
  }
320
452
  //#endregion
453
+ //#region src/resources/reviews.d.ts
454
+ interface ReviewSubmitArgs {
455
+ /** `courseId` from `courses.reviewState()`. */
456
+ courseId: string;
457
+ /** 0.5, 1, 1.5, … 5. Anything else rejects with `INVALID_SCORE` before sending. */
458
+ score: number;
459
+ /**
460
+ * The review text, always sent: an update replaces the stored comment with
461
+ * this value, so pass the existing text to keep it and `""` to clear it.
462
+ * Leaving it out rejects with `CONFIG` rather than clear it by accident.
463
+ */
464
+ comment: string;
465
+ }
466
+ interface ReviewDeleteArgs {
467
+ /** `courseId` from `courses.reviewState()`. */
468
+ courseId: string;
469
+ /**
470
+ * The review to delete: the member's own is `review.id` from
471
+ * `courses.reviewState()`; an owner or manager may pass any review's id.
472
+ */
473
+ reviewId: string;
474
+ }
475
+ /**
476
+ * Course reviews written by the signed-in member, plus deletion for the
477
+ * site's owner and managers (moderation). Browser on the site's own
478
+ * domain only; elsewhere every method rejects with `AUTH_REQUIRED`.
479
+ *
480
+ * Each submit emails the site owner. Call `submit` from an explicit member
481
+ * action (a Submit button), never from autosave or on every star change.
482
+ */
483
+ declare class ReviewsResource {
484
+ private client;
485
+ private auth;
486
+ constructor(client: LoopwiseClient, auth: AuthResource);
487
+ /**
488
+ * Create the member's review of a course, or replace it if they already
489
+ * have one. Resolves to the stored review.
490
+ *
491
+ * Rejects with `LoopwiseError` code `INVALID_SCORE`, `NOT_SIGNED_IN`,
492
+ * `NOT_PURCHASED`, `RATINGS_OFF` or `AUTH_REQUIRED`.
493
+ *
494
+ * @example
495
+ * form.addEventListener('submit', async (event) => {
496
+ * event.preventDefault()
497
+ * const review = await loopwise.reviews.submit({
498
+ * courseId: state.courseId,
499
+ * score: Number(form.score.value),
500
+ * comment: form.comment.value,
501
+ * })
502
+ * })
503
+ */
504
+ submit(args: ReviewSubmitArgs): Promise<MemberReview>;
505
+ /**
506
+ * Delete a review of a course.
507
+ *
508
+ * The server allows the review's author, and the site's owner or
509
+ * managers (for moderation); anyone else is refused. Rejects with
510
+ * `NOT_SIGNED_IN` or `AUTH_REQUIRED`; a review the member may not delete,
511
+ * or one that no longer exists, rejects with `GRAPHQL`.
512
+ */
513
+ delete(args: ReviewDeleteArgs): Promise<void>;
514
+ private mapError;
515
+ private notSignedIn;
516
+ }
517
+ //#endregion
321
518
  //#region src/index.d.ts
322
519
  interface Loopwise {
520
+ readonly auth: AuthResource;
323
521
  readonly courses: CoursesResource;
324
522
  readonly orders: OrdersResource;
325
523
  readonly posts: PostsResource;
524
+ readonly reviews: ReviewsResource;
326
525
  /**
327
526
  * Compose multiple resource queries into a single GraphQL HTTP request.
328
527
  * @example
@@ -353,4 +552,4 @@ interface Loopwise {
353
552
  }
354
553
  declare function createClient(config: LoopwiseConfig): Loopwise;
355
554
  //#endregion
356
- export { type Course, type FetchOptions, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type Order, type OrderItem, type OrderStatus, type PageInfo, type Post, QueryBuilder, type Selected, type Selection, createClient };
555
+ export { type Course, type CourseRating, type CourseReviewState, type CourseReviewStatus, type FetchOptions, type GraphQLError, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type LoopwiseErrorCode, type Member, type MemberReview, type Order, type OrderItem, type OrderStatus, type PageInfo, type Post, QueryBuilder, type Selected, type Selection, createClient };
package/dist/index.mjs CHANGED
@@ -98,7 +98,37 @@ var LoopwiseClient = class {
98
98
  }
99
99
  return result;
100
100
  }
101
+ /**
102
+ * POST a document on behalf of the signed-in member and return the whole
103
+ * GraphQL response, errors included, so the caller can map service error
104
+ * codes. Refuses wherever the member's `token` cookie cannot ride along.
105
+ */
106
+ async member(payload) {
107
+ this.assertMemberOrigin();
108
+ return this.request(payload, { cache: "no-store" });
109
+ }
110
+ graphqlError(errors, code = "GRAPHQL", message) {
111
+ return new LoopwiseError(code, message ?? errors[0]?.message ?? "GraphQL error", {
112
+ site: this.config.site,
113
+ endpoint: this.endpoint,
114
+ graphqlErrors: errors
115
+ });
116
+ }
117
+ assertMemberOrigin() {
118
+ const detail = {
119
+ site: this.config.site,
120
+ endpoint: this.endpoint
121
+ };
122
+ if (detectRuntime() !== "browser") throw new LoopwiseError("AUTH_REQUIRED", "Member actions run only in the browser on the site's own domain; server-side code has no signed-in member.", detail);
123
+ const target = new URL(this.endpoint, window.location.href).origin;
124
+ if (target !== window.location.origin) throw new LoopwiseError("AUTH_REQUIRED", `Member actions need the page and the API on one origin; this page is ${window.location.origin} but the API is ${target}, so the member's sign-in cookie is not sent.`, detail);
125
+ }
101
126
  async fetchGraphQL(payload, options = {}) {
127
+ const body = await this.request(payload, options);
128
+ if (body.errors?.length) throw this.graphqlError(body.errors);
129
+ return body.data ?? null;
130
+ }
131
+ async request(payload, options) {
102
132
  const headers = { accept: "application/graphql-response+json, application/json" };
103
133
  const apiKey = this.resolveApiKey();
104
134
  if (apiKey) headers.authorization = `Bearer ${apiKey}`;
@@ -146,12 +176,7 @@ var LoopwiseClient = class {
146
176
  });
147
177
  body = errorBody;
148
178
  }
149
- if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
150
- site: this.config.site,
151
- endpoint: this.endpoint,
152
- graphqlErrors: body.errors
153
- });
154
- return body.data ?? null;
179
+ return body;
155
180
  }
156
181
  shouldUseGet(payload, options) {
157
182
  if (typeof options.cache !== "number" || options.cache <= 0) return false;
@@ -273,6 +298,58 @@ var QueryBuilder = class {
273
298
  }
274
299
  };
275
300
  //#endregion
301
+ //#region src/schema/auth.ts
302
+ const ME_QUERY = "query Me($subdomain: String!) { user(subdomain: $subdomain) { id name } }";
303
+ function mapMember(raw) {
304
+ return {
305
+ id: raw.id,
306
+ name: raw.name ?? null
307
+ };
308
+ }
309
+ function serviceCode(error) {
310
+ if (typeof error.code === "string") return error.code;
311
+ const ext = error.extensions?.code;
312
+ return typeof ext === "string" ? ext : void 0;
313
+ }
314
+ function isForeignMemberError(error) {
315
+ return error.path?.[0] === "user" && serviceCode(error) === "User-000";
316
+ }
317
+ //#endregion
318
+ //#region src/resources/auth.ts
319
+ /**
320
+ * The member signed in to this site, read from the page's own sign-in
321
+ * cookie. Works only in the browser on the site's own domain; elsewhere
322
+ * every method rejects with `LoopwiseError` code `'AUTH_REQUIRED'` instead
323
+ * of reporting a signed-out visitor.
324
+ */
325
+ var AuthResource = class {
326
+ client;
327
+ constructor(client) {
328
+ this.client = client;
329
+ }
330
+ /**
331
+ * The signed-in member, or `null` for a signed-out visitor.
332
+ * @example
333
+ * const me = await loopwise.auth.getMe()
334
+ * greeting.textContent = me ? `Hi ${me.name ?? ''}` : 'Sign in'
335
+ */
336
+ async getMe() {
337
+ const body = await this.client.member({
338
+ query: ME_QUERY,
339
+ variables: { subdomain: this.client.config.site ?? "" },
340
+ operationName: "Me"
341
+ });
342
+ const errors = body.errors ?? [];
343
+ if (errors.length > 0 && !errors.every(isForeignMemberError)) throw this.client.graphqlError(errors);
344
+ const raw = body.data?.user;
345
+ return raw ? mapMember(raw) : null;
346
+ }
347
+ /** Whether a member is signed in to this site. */
348
+ async isAuthenticated() {
349
+ return await this.getMe() !== null;
350
+ }
351
+ };
352
+ //#endregion
276
353
  //#region src/schema/courses.ts
277
354
  /** Map SDK Course selection -> schema field selection set. */
278
355
  function buildCourseSelection(select) {
@@ -322,6 +399,25 @@ function mapCoursePage(raw) {
322
399
  };
323
400
  }
324
401
  //#endregion
402
+ //#region src/schema/reviews.ts
403
+ const REVIEW_STATE_QUERY = "query CourseReviewState($subdomain: String!, $slug: String!) { user(subdomain: $subdomain) { id } course(subdomain: $subdomain, slug: $slug) { id isRatingActive isPurchased reviewed { id score comment } rating { average total } } }";
404
+ const SUBMIT_REVIEW_MUTATION = "mutation SubmitReview($resourceInput: ResourceInput!, $rateInput: RateInput!) { createOrUpdateRate(resourceInput: $resourceInput, rateInput: $rateInput) { rate { id score comment } } }";
405
+ const DELETE_REVIEW_MUTATION = "mutation DeleteReview($subdomain: String!, $resourceInput: ResourceInput!, $rateId: String!) { deleteRate(subdomain: $subdomain, resourceInput: $resourceInput, rateId: $rateId) { success } }";
406
+ function mapMemberReview(raw) {
407
+ return {
408
+ id: raw.id,
409
+ score: raw.score,
410
+ comment: raw.comment ?? ""
411
+ };
412
+ }
413
+ function isValidScore(score) {
414
+ return typeof score === "number" && Number.isFinite(score) && score >= .5 && score <= 5 && Number.isInteger(score * 2);
415
+ }
416
+ function currentLoginUrl() {
417
+ const { pathname, search, hash } = window.location;
418
+ return `/login?redirect=${encodeURIComponent(pathname + search + hash)}`;
419
+ }
420
+ //#endregion
325
421
  //#region src/resources/courses.ts
326
422
  const DEFAULT_LIST_FIELDS$1 = {
327
423
  id: true,
@@ -403,7 +499,59 @@ var CoursesResource = class {
403
499
  transform: (raw) => mapCourse(raw)
404
500
  }, options);
405
501
  }
502
+ /**
503
+ * What the signed-in member can do about reviewing a course, in one
504
+ * request: whether reviews are on, whether they are signed in and
505
+ * enrolled, their own review, and the course's rating summary.
506
+ *
507
+ * Member state: call it in the browser on the site's own domain, after
508
+ * the page loads. It never uses the `cache` option, and it rejects with
509
+ * `LoopwiseError` code `AUTH_REQUIRED` server-side or off-origin rather
510
+ * than report every visitor as signed out. An unknown slug rejects with
511
+ * `GRAPHQL`.
512
+ *
513
+ * @example
514
+ * const state = await loopwise.courses.reviewState({ slug: 'finance' })
515
+ * switch (state.status) {
516
+ * case 'ratings_off': return hide()
517
+ * case 'signed_out': return showLink(state.loginUrl)
518
+ * case 'not_purchased': return showText('Enroll to review this course')
519
+ * case 'can_review': return showForm()
520
+ * case 'reviewed': return showForm(state.review)
521
+ * }
522
+ */
523
+ async reviewState(args) {
524
+ if (typeof args?.slug !== "string" || args.slug === "") throw new LoopwiseError("CONFIG", "slug is required");
525
+ const body = await this.client.member({
526
+ query: REVIEW_STATE_QUERY,
527
+ variables: {
528
+ subdomain: this.client.config.site ?? "",
529
+ slug: args.slug
530
+ },
531
+ operationName: "CourseReviewState"
532
+ });
533
+ const errors = body.errors ?? [];
534
+ if (errors.length > 0 && !errors.every(isForeignMemberError)) throw this.client.graphqlError(errors);
535
+ const course = body.data?.course;
536
+ if (!course) throw this.client.graphqlError(errors, "GRAPHQL", `No course with slug "${args.slug}"`);
537
+ const signedIn = body.data?.user != null;
538
+ const review = signedIn && course.reviewed ? mapMemberReview(course.reviewed) : null;
539
+ return {
540
+ status: reviewStatus(course, signedIn, review !== null),
541
+ courseId: course.id,
542
+ review,
543
+ rating: course.rating ?? null,
544
+ loginUrl: currentLoginUrl()
545
+ };
546
+ }
406
547
  };
548
+ function reviewStatus(course, signedIn, reviewed) {
549
+ if (!course.isRatingActive) return "ratings_off";
550
+ if (!signedIn) return "signed_out";
551
+ if (reviewed) return "reviewed";
552
+ if (!course.isPurchased) return "not_purchased";
553
+ return "can_review";
554
+ }
407
555
  //#endregion
408
556
  //#region src/schema/orders.ts
409
557
  /** Map SDK Order selection -> schema field selection set. */
@@ -647,13 +795,114 @@ var PostsResource = class {
647
795
  }
648
796
  };
649
797
  //#endregion
798
+ //#region src/resources/reviews.ts
799
+ /**
800
+ * Course reviews written by the signed-in member, plus deletion for the
801
+ * site's owner and managers (moderation). Browser on the site's own
802
+ * domain only; elsewhere every method rejects with `AUTH_REQUIRED`.
803
+ *
804
+ * Each submit emails the site owner. Call `submit` from an explicit member
805
+ * action (a Submit button), never from autosave or on every star change.
806
+ */
807
+ var ReviewsResource = class {
808
+ client;
809
+ auth;
810
+ constructor(client, auth) {
811
+ this.client = client;
812
+ this.auth = auth;
813
+ }
814
+ /**
815
+ * Create the member's review of a course, or replace it if they already
816
+ * have one. Resolves to the stored review.
817
+ *
818
+ * Rejects with `LoopwiseError` code `INVALID_SCORE`, `NOT_SIGNED_IN`,
819
+ * `NOT_PURCHASED`, `RATINGS_OFF` or `AUTH_REQUIRED`.
820
+ *
821
+ * @example
822
+ * form.addEventListener('submit', async (event) => {
823
+ * event.preventDefault()
824
+ * const review = await loopwise.reviews.submit({
825
+ * courseId: state.courseId,
826
+ * score: Number(form.score.value),
827
+ * comment: form.comment.value,
828
+ * })
829
+ * })
830
+ */
831
+ async submit(args) {
832
+ assertCourseId(args.courseId);
833
+ if (!isValidScore(args.score)) throw new LoopwiseError("INVALID_SCORE", `score must be 0.5 to 5 in steps of 0.5; got ${String(args.score)}`);
834
+ const { comment } = args;
835
+ if (typeof comment !== "string") throw new LoopwiseError("CONFIG", "comment is required: pass the review text, or \"\" to clear it");
836
+ const body = await this.client.member({
837
+ query: SUBMIT_REVIEW_MUTATION,
838
+ variables: {
839
+ resourceInput: {
840
+ resourceType: "Course",
841
+ resourceId: args.courseId
842
+ },
843
+ rateInput: {
844
+ score: args.score,
845
+ comment
846
+ }
847
+ },
848
+ operationName: "SubmitReview"
849
+ });
850
+ if (body.errors?.length) throw await this.mapError(body.errors);
851
+ const payload = body.data?.createOrUpdateRate;
852
+ if (!payload?.rate) throw new LoopwiseError("GRAPHQL", "The review was not saved", { site: this.client.config.site });
853
+ return mapMemberReview(payload.rate);
854
+ }
855
+ /**
856
+ * Delete a review of a course.
857
+ *
858
+ * The server allows the review's author, and the site's owner or
859
+ * managers (for moderation); anyone else is refused. Rejects with
860
+ * `NOT_SIGNED_IN` or `AUTH_REQUIRED`; a review the member may not delete,
861
+ * or one that no longer exists, rejects with `GRAPHQL`.
862
+ */
863
+ async delete(args) {
864
+ assertCourseId(args.courseId);
865
+ if (typeof args.reviewId !== "string" || args.reviewId === "") throw new LoopwiseError("CONFIG", "reviewId is required");
866
+ const body = await this.client.member({
867
+ query: DELETE_REVIEW_MUTATION,
868
+ variables: {
869
+ subdomain: this.client.config.site ?? "",
870
+ resourceInput: {
871
+ resourceType: "Course",
872
+ resourceId: args.courseId
873
+ },
874
+ rateId: args.reviewId
875
+ },
876
+ operationName: "DeleteReview"
877
+ });
878
+ if (body.errors?.length) throw (body.errors.some((e) => serviceCode(e) === "User-000") ? await this.auth.isAuthenticated() : true) ? this.client.graphqlError(body.errors) : this.notSignedIn(body.errors);
879
+ if (!(body.data?.deleteRate)?.success) throw new LoopwiseError("GRAPHQL", "The review was not deleted", { site: this.client.config.site });
880
+ }
881
+ async mapError(errors) {
882
+ const codes = errors.map(serviceCode);
883
+ if (codes.includes("Rate-001")) return this.client.graphqlError(errors, "INVALID_SCORE", "score must be 0.5 to 5 in steps of 0.5");
884
+ if (codes.includes("Rate-002")) return this.client.graphqlError(errors, "RATINGS_OFF", "Reviews are turned off for this course");
885
+ if (codes.includes("User-000")) return await this.auth.isAuthenticated() ? this.client.graphqlError(errors, "NOT_PURCHASED", "Only members enrolled in this course can review it") : this.notSignedIn(errors);
886
+ return this.client.graphqlError(errors);
887
+ }
888
+ notSignedIn(errors) {
889
+ return this.client.graphqlError(errors, "NOT_SIGNED_IN", "Sign in to review this course");
890
+ }
891
+ };
892
+ function assertCourseId(courseId) {
893
+ if (typeof courseId !== "string" || courseId === "") throw new LoopwiseError("CONFIG", "courseId is required");
894
+ }
895
+ //#endregion
650
896
  //#region src/index.ts
651
897
  function createClient(config) {
652
898
  const client = new LoopwiseClient(config);
899
+ const auth = new AuthResource(client);
653
900
  return {
901
+ auth,
654
902
  courses: new CoursesResource(client),
655
903
  orders: new OrdersResource(client),
656
904
  posts: new PostsResource(client),
905
+ reviews: new ReviewsResource(client, auth),
657
906
  batch(builders, options) {
658
907
  return client.batch(builders, options);
659
908
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopwise/sdk",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Loopwise platform SDK — agent-friendly headless API client",
5
5
  "repository": {
6
6
  "type": "git",