@openstax/ts-utils 1.4.1 → 1.5.1

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.
@@ -1,5 +1,4 @@
1
- import { assertDefined } from '../assertions';
2
- import { ifDefined } from '../guards';
1
+ import { resolveConfigValue } from '.';
3
2
  /**
4
3
  * A list of environment variables that were requested at build time. Used by webpack to
5
4
  * capture build-time environment variables values.
@@ -32,6 +31,16 @@ export const envConfig = (name, type = 'build', defaultValue) => {
32
31
  // - https://github.com/webpack/webpack/issues/14800
33
32
  // - https://github.com/webpack/webpack/issues/5392
34
33
  const envs = { ...process.env, ...(typeof __PROCESS_ENV !== 'undefined' ? __PROCESS_ENV : {}) };
35
- return assertDefined(ifDefined(envs[name], defaultValue), `expected to find environment variable with name: ${name}`);
34
+ if (envs[name] === undefined) {
35
+ if (defaultValue === undefined) {
36
+ throw new Error(`expected to find environment variable with name: ${name}`);
37
+ }
38
+ else {
39
+ return resolveConfigValue(defaultValue);
40
+ }
41
+ }
42
+ else {
43
+ return envs[name];
44
+ }
36
45
  };
37
46
  };
@@ -0,0 +1,77 @@
1
+ import { ConfigProviderForConfig } from '../../config';
2
+ import { GenericFetch } from '../../fetch';
3
+ import { JsonCompatibleStruct } from '../../routing';
4
+ import { ApiUser } from '../authProvider';
5
+ import { Logger } from '../logger';
6
+ export declare type Config = {
7
+ accountsBase: string;
8
+ accountsAuthToken: string;
9
+ };
10
+ interface Initializer<C> {
11
+ configSpace?: C;
12
+ fetch: GenericFetch;
13
+ }
14
+ export declare type FindUserPayload = ({
15
+ external_id: string;
16
+ } | {
17
+ uuid: string;
18
+ }) & {
19
+ sso?: string;
20
+ };
21
+ export declare type FindOrCreateUserPayload = {
22
+ external_id: string;
23
+ email?: string;
24
+ already_verified?: boolean;
25
+ first_name?: string;
26
+ last_name?: string;
27
+ full_name?: string;
28
+ salesforce_contact_id?: string;
29
+ faculty_status?: string;
30
+ role?: string;
31
+ school_type?: string;
32
+ is_test?: boolean;
33
+ sso?: string;
34
+ };
35
+ export declare type FindOrCreateUserResponse = {
36
+ id: number;
37
+ uuid: string;
38
+ external_ids: string[];
39
+ is_test: boolean;
40
+ sso: string;
41
+ };
42
+ export declare type FindUserResponse = (FindOrCreateUserResponse & {
43
+ external_ids: string[];
44
+ }) | undefined;
45
+ export declare type LinkUserPayload = {
46
+ userId: number;
47
+ externalId: string;
48
+ };
49
+ export declare type LinkUserResponse = {
50
+ user_id: number;
51
+ external_id: string;
52
+ };
53
+ export declare type SearchUsersPayload = {
54
+ q: string;
55
+ order_by?: string;
56
+ };
57
+ export declare type SearchUsersResponse = {
58
+ items: Array<ApiUser & {
59
+ external_ids: string[];
60
+ } & JsonCompatibleStruct>;
61
+ total_count: number;
62
+ };
63
+ export declare const accountsGateway: <C extends string = "accounts">(initializer: Initializer<C>) => (configProvider: { [key in C]: {
64
+ accountsBase: import("../../config").ConfigValueProvider<string>;
65
+ accountsAuthToken: import("../../config").ConfigValueProvider<string>;
66
+ }; }) => {
67
+ findOrCreateUser: (body: FindOrCreateUserPayload) => Promise<FindOrCreateUserResponse>;
68
+ findUser: (body: FindUserPayload) => Promise<FindUserResponse>;
69
+ getUser: (token: string) => Promise<ApiUser & JsonCompatibleStruct>;
70
+ linkUser: (body: LinkUserPayload) => Promise<LinkUserResponse>;
71
+ mapUserUuids: <T>(userUuidsMap: {
72
+ [uuid: string]: T;
73
+ }, logger: Logger, platformId?: string | undefined) => Promise<[string, T][]>;
74
+ searchUsers: (payload: SearchUsersPayload) => Promise<SearchUsersResponse>;
75
+ };
76
+ export declare type AccountsGateway = ReturnType<ReturnType<typeof accountsGateway>>;
77
+ export {};
@@ -0,0 +1,103 @@
1
+ import { chunk, isEqual } from 'lodash';
2
+ import queryString from 'query-string';
3
+ import { resolveConfigValue } from '../../config';
4
+ import { ifDefined } from '../../guards';
5
+ import { METHOD } from '../../routing';
6
+ import { Level } from '../logger';
7
+ class ApiError extends Error {
8
+ constructor(message, status) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+ export const accountsGateway = (initializer) => (configProvider) => {
14
+ const config = configProvider[ifDefined(initializer.configSpace, 'accounts')];
15
+ const accountsBase = resolveConfigValue(config.accountsBase);
16
+ const accountsAuthToken = resolveConfigValue(config.accountsAuthToken);
17
+ const request = async (method, path, options, statuses = [200, 201]) => {
18
+ const host = (await accountsBase).replace(/\/+$/, '');
19
+ const url = `${host}/api/${path}`;
20
+ const config = {
21
+ headers: {
22
+ Authorization: `Bearer ${options.token || await accountsAuthToken}`,
23
+ },
24
+ method,
25
+ };
26
+ if (options.body) {
27
+ config.body = JSON.stringify(options.body);
28
+ }
29
+ const response = await initializer.fetch(url, config);
30
+ if (!statuses.includes(response.status)) {
31
+ throw new ApiError(`Received unexpected status code ${response.status} for Accounts API call: ${method} ${url}`, response.status);
32
+ }
33
+ return response.json();
34
+ };
35
+ const findOrCreateUser = async (body) => request(METHOD.POST, 'user/find-or-create', { body });
36
+ const findUser = async (body) => {
37
+ try {
38
+ return await request(METHOD.POST, 'user/find', { body });
39
+ }
40
+ catch (error) {
41
+ if (error instanceof ApiError && error.status === 404) {
42
+ return undefined;
43
+ }
44
+ else {
45
+ throw error;
46
+ }
47
+ }
48
+ };
49
+ const getUser = async (token) => request(METHOD.GET, 'user', { token });
50
+ const linkUser = async (body) => request(METHOD.POST, 'user/external-ids', {
51
+ body: {
52
+ external_id: body.externalId,
53
+ user_id: body.userId,
54
+ }
55
+ });
56
+ const searchUsers = async (payload) => request(METHOD.GET, `users?${queryString.stringify(payload)}`, {});
57
+ const getPlatformUserId = (externalIds, platformId) => {
58
+ for (const externalId of externalIds) {
59
+ const [userPlatformId, userId] = externalId.split('/', 2);
60
+ if (userPlatformId === platformId) {
61
+ return userId;
62
+ }
63
+ }
64
+ };
65
+ /*
66
+ * If a platformId is given, returns an array where
67
+ * the first element is the user id from the platform
68
+ * and the second is the value from the map
69
+ * Otherwise, returns an array where
70
+ * the first element is the user's full_name
71
+ * and the second is the value from the map
72
+ */
73
+ const mapUserUuids = async (userUuidsMap, logger, platformId) => {
74
+ const results = [];
75
+ // Accounts will not return any results if this search returns more than 10 users
76
+ const chunkedUuids = chunk(Object.keys(userUuidsMap), 10);
77
+ await Promise.all(chunkedUuids.map(async (uuids) => {
78
+ const { items } = await searchUsers({ q: uuids.map((uuid) => `uuid:${uuid}`).join(' ') });
79
+ const accountsUuids = items.map((user) => user.uuid);
80
+ if (!isEqual(accountsUuids.sort(), uuids.sort())) {
81
+ logger.logEvent(Level.Warn, {
82
+ message: 'Unexpected Accounts user search results',
83
+ uuids,
84
+ accountsUuids,
85
+ });
86
+ }
87
+ items.forEach((user) => {
88
+ const userId = platformId ? getPlatformUserId(user.external_ids, platformId) : user.full_name;
89
+ if (!userId) {
90
+ const missing = platformId ? 'external_id matching the given platformId' : 'full_name';
91
+ logger.logEvent(Level.Warn, {
92
+ message: `Accounts user has no ${missing}`,
93
+ accountsUuid: user.uuid,
94
+ platformId,
95
+ });
96
+ }
97
+ results.push([userId || 'N/A', userUuidsMap[user.uuid]]);
98
+ });
99
+ }));
100
+ return results;
101
+ };
102
+ return { findOrCreateUser, findUser, getUser, linkUser, mapUserUuids, searchUsers };
103
+ };
@@ -1,3 +1,4 @@
1
+ import { Headers } from 'node-fetch';
1
2
  import { ConfigProviderForConfig } from '../../config';
2
3
  import { ConfigForFetch, GenericFetch, Response } from '../../fetch';
3
4
  import { AnyRoute, ApiResponse, OutputForRoute, ParamsForRoute, PayloadForRoute, QueryParams } from '../../routing';
@@ -24,11 +25,13 @@ interface AcceptStatus<Ro> {
24
25
  <S extends number[]>(...args: S): ApiClientResponse<any>;
25
26
  }
26
27
  declare type UnsafeApiClientResponse<Ro> = {
28
+ headers: Headers;
27
29
  load: () => Promise<any>;
28
30
  status: number;
29
31
  acceptStatus: AcceptStatus<Ro>;
30
32
  };
31
33
  declare type ApiClientResponse<Ro> = Ro extends any ? {
34
+ headers: Headers;
32
35
  status: TResponseStatus<Ro>;
33
36
  load: () => Promise<TResponsePayload<Ro>>;
34
37
  } : never;
@@ -2,7 +2,7 @@ import { ConfigProviderForConfig } from '../../config';
2
2
  import { FetchConfig, GenericFetch } from '../../fetch';
3
3
  import { User } from '.';
4
4
  declare type Config = {
5
- accountsUrl: string;
5
+ accountsBase: string;
6
6
  };
7
7
  interface Initializer<C> {
8
8
  configSpace?: C;
@@ -28,7 +28,7 @@ export interface Window {
28
28
  removeEventListener: (event: 'message', callback: EventHandler) => void;
29
29
  }
30
30
  export declare const browserAuthProvider: <C extends string = "auth">({ window, configSpace }: Initializer<C>) => (configProvider: { [key in C]: {
31
- accountsUrl: import("../../config").ConfigValueProvider<string>;
31
+ accountsBase: import("../../config").ConfigValueProvider<string>;
32
32
  }; }) => {
33
33
  /**
34
34
  * adds auth parameters to the url. this is only safe to use when using javascript to navigate
@@ -4,7 +4,7 @@ import { ifDefined } from '../../guards';
4
4
  import { embeddedAuthProvider, PostMessageTypes } from './utils/embeddedAuthProvider';
5
5
  export const browserAuthProvider = ({ window, configSpace }) => (configProvider) => {
6
6
  const config = configProvider[ifDefined(configSpace, 'auth')];
7
- const accountsUrl = once(() => resolveConfigValue(config.accountsUrl));
7
+ const accountsBase = once(() => resolveConfigValue(config.accountsBase));
8
8
  const queryString = window.location.search;
9
9
  const queryKey = 'auth';
10
10
  const authQuery = new URLSearchParams(queryString).get(queryKey);
@@ -68,7 +68,7 @@ export const browserAuthProvider = ({ window, configSpace }) => (configProvider)
68
68
  * requests user identity from accounts api using given token or cookie
69
69
  */
70
70
  const getFetchUser = async () => {
71
- const response = await window.fetch((await accountsUrl()).replace(/\/+$/, '') + '/accounts/api/user', getAuthorizedFetchConfigFromData(userData));
71
+ const response = await window.fetch((await accountsBase()).replace(/\/+$/, '') + '/api/user', getAuthorizedFetchConfigFromData(userData));
72
72
  if (response.status === 200) {
73
73
  return { ...userData, user: await response.json() };
74
74
  }
@@ -3,7 +3,7 @@ import { GenericFetch } from '../../fetch';
3
3
  import { CookieAuthProvider } from '.';
4
4
  declare type Config = {
5
5
  cookieName: string;
6
- accountsUrl: string;
6
+ accountsBase: string;
7
7
  };
8
8
  interface Initializer<C> {
9
9
  configSpace?: C;
@@ -11,6 +11,6 @@ interface Initializer<C> {
11
11
  }
12
12
  export declare const subrequestAuthProvider: <C extends string = "subrequest">(initializer: Initializer<C>) => (configProvider: { [key in C]: {
13
13
  cookieName: import("../../config").ConfigValueProvider<string>;
14
- accountsUrl: import("../../config").ConfigValueProvider<string>;
14
+ accountsBase: import("../../config").ConfigValueProvider<string>;
15
15
  }; }) => CookieAuthProvider;
16
16
  export {};
@@ -5,7 +5,7 @@ import { getAuthTokenOrCookie } from '.';
5
5
  export const subrequestAuthProvider = (initializer) => (configProvider) => {
6
6
  const config = configProvider[ifDefined(initializer.configSpace, 'subrequest')];
7
7
  const cookieName = once(() => resolveConfigValue(config.cookieName));
8
- const accountsUrl = once(() => resolveConfigValue(config.accountsUrl));
8
+ const accountsBase = once(() => resolveConfigValue(config.accountsBase));
9
9
  return ({ request, profile }) => {
10
10
  let user;
11
11
  const getAuthorizedFetchConfig = profile.track('getAuthorizedFetchConfig', () => async () => {
@@ -20,7 +20,7 @@ export const subrequestAuthProvider = (initializer) => (configProvider) => {
20
20
  if (!token) {
21
21
  return undefined;
22
22
  }
23
- return p.trackFetch(initializer.fetch)(await accountsUrl(), { headers })
23
+ return p.trackFetch(initializer.fetch)((await accountsBase()).replace(/\/+$/, '') + '/api/user', { headers })
24
24
  .then(response => response.json());
25
25
  });
26
26
  return {
@@ -0,0 +1,32 @@
1
+ import { AccountsGateway } from '../accountsGateway';
2
+ import { AuthProvider } from '../authProvider';
3
+ import { Logger } from '../logger';
4
+ import { LrsGateway } from '.';
5
+ export interface Grade {
6
+ scoreGiven: number;
7
+ scoreMaximum: number;
8
+ comment?: string;
9
+ activityProgress: 'Initialized' | 'Started' | 'inProgress' | 'Submitted' | 'Completed';
10
+ gradingProgress: 'FullyGraded' | 'Pending' | 'PendingManual' | 'Failed' | 'NotReady';
11
+ userId: string;
12
+ }
13
+ export declare const getRegistrationAttemptInfo: (lrs: LrsGateway, registration: string, options?: {
14
+ anyUser?: boolean | undefined;
15
+ } | undefined) => Promise<{
16
+ [key: string]: import("./attempt-utils").ActivityState;
17
+ }>;
18
+ export declare const getCurrentGrade: (services: {
19
+ lrs: LrsGateway;
20
+ ltiAuthProvider: AuthProvider;
21
+ }, registration: string, options?: {
22
+ scoreMaximum?: number | undefined;
23
+ userId?: string | undefined;
24
+ } | undefined) => Promise<Grade | null>;
25
+ export declare const getAllGradesForAssignment: (services: {
26
+ accountsGateway: AccountsGateway;
27
+ lrs: LrsGateway;
28
+ logger: Logger;
29
+ }, registration: string, options?: {
30
+ platformId?: string | undefined;
31
+ scoreMaximum?: number | undefined;
32
+ } | undefined) => Promise<Grade[]>;
@@ -0,0 +1,55 @@
1
+ import { roundToPrecision } from '../..';
2
+ import { resolveAttemptInfo } from './attempt-utils';
3
+ export const getRegistrationAttemptInfo = async (lrs, registration, options) => {
4
+ const allStatements = await lrs.getAllXapiStatements({ ...options, registration, ensureSync: true });
5
+ // Partition statements for each user
6
+ const statementsPerUser = {};
7
+ allStatements.forEach((statement) => {
8
+ var _a;
9
+ // If we ever support accounts from different statement.actor.account.homePage, this method may need changes
10
+ // (unless we can guarantee the statement.actor.account.name are unique)
11
+ statementsPerUser[_a = statement.actor.account.name] || (statementsPerUser[_a] = []);
12
+ statementsPerUser[statement.actor.account.name].push(statement);
13
+ });
14
+ const result = {};
15
+ for (const [userUuid, userStatements] of Object.entries(statementsPerUser)) {
16
+ result[userUuid] = resolveAttemptInfo(userStatements);
17
+ }
18
+ return result;
19
+ };
20
+ // generates a payload that can be sent to the LMS, documentation here:
21
+ // lti: http://www.imsglobal.org/spec/lti-ags/v2p0#score-publish-service
22
+ // ltijs: https://cvmcosta.me/ltijs/#/grading
23
+ const getInfoGrade = (info, userId, maxScore) => {
24
+ var _a;
25
+ const completed = info.currentAttemptCompleted;
26
+ const { raw, scaled, max } = ((_a = completed === null || completed === void 0 ? void 0 : completed.result) === null || _a === void 0 ? void 0 : _a.score) || {};
27
+ const scoreMaximum = maxScore !== null && maxScore !== void 0 ? maxScore : 100;
28
+ const scoreGiven = raw && max
29
+ ? scoreMaximum / max * raw
30
+ : scaled
31
+ ? scaled * scoreMaximum
32
+ : 0;
33
+ return {
34
+ userId,
35
+ activityProgress: completed ? 'Completed' : 'Started',
36
+ gradingProgress: completed ? 'FullyGraded' : 'NotReady',
37
+ scoreMaximum,
38
+ scoreGiven: roundToPrecision(scoreGiven, -2),
39
+ };
40
+ };
41
+ export const getCurrentGrade = async (services, registration, options) => {
42
+ var _a;
43
+ const user = await services.ltiAuthProvider.getUser();
44
+ if (!user) {
45
+ return null;
46
+ }
47
+ const infoPerUser = await getRegistrationAttemptInfo(services.lrs, registration);
48
+ const userInfo = infoPerUser[user.uuid];
49
+ return getInfoGrade(userInfo !== null && userInfo !== void 0 ? userInfo : resolveAttemptInfo([]), (_a = options === null || options === void 0 ? void 0 : options.userId) !== null && _a !== void 0 ? _a : user.uuid, options === null || options === void 0 ? void 0 : options.scoreMaximum);
50
+ };
51
+ export const getAllGradesForAssignment = async (services, registration, options) => {
52
+ const infoPerUserUuid = await getRegistrationAttemptInfo(services.lrs, registration, { anyUser: true });
53
+ const mappedResults = await services.accountsGateway.mapUserUuids(infoPerUserUuid, services.logger, options === null || options === void 0 ? void 0 : options.platformId);
54
+ return mappedResults.map(([userId, userInfo]) => getInfoGrade(userInfo, userId, options === null || options === void 0 ? void 0 : options.scoreMaximum));
55
+ };