@argentic/chest-sdk 0.1.0 → 0.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.
- package/README.md +391 -48
- package/client/index.ts +10 -7
- package/client/src/api.ts +81 -0
- package/client/src/errors.ts +14 -3
- package/client/src/events.ts +228 -0
- package/client/src/files.ts +95 -89
- package/client/src/member.ts +40 -17
- package/client/src/members.ts +167 -0
- package/client/src/notifications.ts +148 -0
- package/client/src/testing.ts +417 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +9 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +88 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/errors.d.ts +3 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +13 -3
- package/dist/src/errors.js.map +1 -1
- package/dist/src/events.d.ts +56 -0
- package/dist/src/events.d.ts.map +1 -0
- package/dist/src/events.js +193 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/files.d.ts +17 -1
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +96 -92
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +6 -3
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +22 -11
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +34 -0
- package/dist/src/members.d.ts.map +1 -0
- package/dist/src/members.js +146 -0
- package/dist/src/members.js.map +1 -0
- package/dist/src/notifications.d.ts +25 -0
- package/dist/src/notifications.d.ts.map +1 -0
- package/dist/src/notifications.js +121 -0
- package/dist/src/notifications.js.map +1 -0
- package/dist/src/testing.d.ts +70 -0
- package/dist/src/testing.d.ts.map +1 -0
- package/dist/src/testing.js +419 -0
- package/dist/src/testing.js.map +1 -0
- package/package.json +28 -4
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { ask, json, refusal } from "./api.js";
|
|
2
|
+
import { ChestError, Unavailable } from "./errors.js";
|
|
3
|
+
import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
|
|
4
|
+
|
|
5
|
+
// Who has the tool, for a server tool whose chest.json declares
|
|
6
|
+
// "capabilities": ["members"] (and "members.email" for their addresses):
|
|
7
|
+
// exactly the members who have access to it at the time of the call — by a
|
|
8
|
+
// grant, a group, open to all, or because they run it. A member without
|
|
9
|
+
// access is indistinguishable from an identifier that does not exist.
|
|
10
|
+
//
|
|
11
|
+
// import * as members from "@argentic/chest-sdk/members";
|
|
12
|
+
// const { members: page, next } = await members.list({ q: "cam" });
|
|
13
|
+
// const one = await members.get("mbr_…"); // null: no such member here
|
|
14
|
+
// const { members: found, former, unknown } = await members.lookup(ids);
|
|
15
|
+
// const all = await members.groups.list(); // groups that give the tool
|
|
16
|
+
//
|
|
17
|
+
// Store member identifiers in your data, never names or addresses: resolve
|
|
18
|
+
// them when rendering, with lookup. Errors: CapabilityNotGranted (403),
|
|
19
|
+
// RateLimited (429, 600 calls a minute), Unavailable (503, or the Chest not
|
|
20
|
+
// reached), ChestError otherwise (invalid_id, invalid_query 400).
|
|
21
|
+
|
|
22
|
+
// A page of the list, and the cursor of the next one (null after the last).
|
|
23
|
+
export type MemberPage = { members: Member[]; next: string | null };
|
|
24
|
+
// A member who left the Chest after having the tool: "former" with the name
|
|
25
|
+
// they had, or "erased" without any once the owner had their data erased —
|
|
26
|
+
// render “Former member”.
|
|
27
|
+
export type FormerMember = { id: string; name: string | null; status: "former" | "erased" };
|
|
28
|
+
// What a lookup found: members who have the tool, former members, and
|
|
29
|
+
// identifiers the tool does not know.
|
|
30
|
+
export type Lookup = { members: Member[]; former: FormerMember[]; unknown: string[] };
|
|
31
|
+
// A group that gives the tool, with the identifiers of its members.
|
|
32
|
+
export type Group = { id: string; name: string; members: string[] };
|
|
33
|
+
|
|
34
|
+
const maxLimit = 500;
|
|
35
|
+
const lookupBatch = 200;
|
|
36
|
+
const cacheTime = 60_000;
|
|
37
|
+
const cacheSize = 5000;
|
|
38
|
+
|
|
39
|
+
function checkId(id: unknown): string {
|
|
40
|
+
if (typeof id !== "string" || !memberIdPattern.test(id)) throw new ChestError("invalid_id", 400, "invalid member identifier");
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text = (value: unknown, max: number): value is string => typeof value === "string" && value.length <= max;
|
|
45
|
+
|
|
46
|
+
// shown reads a member as the Chest answers it; anything else is not the
|
|
47
|
+
// Chest's answer.
|
|
48
|
+
function shown(value: unknown): Member {
|
|
49
|
+
const m = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
50
|
+
if (!m || typeof m["id"] !== "string" || !memberIdPattern.test(m["id"]) || !text(m["first_name"], 256) || !text(m["last_name"], 256) || !text(m["name"], 520) || !(m["photo"] === null || text(m["photo"], 200)) || !(m["role"] === null || text(m["role"], 48)) || typeof m["admin"] !== "boolean" || typeof m["builder"] !== "boolean" || !Array.isArray(m["groups"]) || m["groups"].length > 16 || !m["groups"].every(g => typeof g === "string" && groupIdPattern.test(g)) || !(m["email"] === undefined || text(m["email"], 254))) throw new Unavailable();
|
|
51
|
+
return { id: m["id"], firstName: m["first_name"], lastName: m["last_name"], name: m["name"], photo: m["photo"], role: m["role"], isAdmin: m["admin"], isBuilder: m["builder"], groups: [...m["groups"]] as string[], ...(m["email"] === undefined ? {} : { email: m["email"] }) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// list says the members who have the tool, by name then identifier, limit
|
|
55
|
+
// at a time (100 by default, 500 at most), after the cursor of the previous
|
|
56
|
+
// page. q finds the start of a first name, a last name or a name (and of an
|
|
57
|
+
// address with members.email), whatever its case and accents; role and group
|
|
58
|
+
// keep the members of that role, or of that group.
|
|
59
|
+
export async function list(options: { after?: string; limit?: number; q?: string; role?: string; group?: string } = {}): Promise<MemberPage> {
|
|
60
|
+
const query = new URLSearchParams();
|
|
61
|
+
if (options.after !== undefined) query.set("after", options.after);
|
|
62
|
+
if (options.limit !== undefined) {
|
|
63
|
+
if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > maxLimit) throw new ChestError("invalid_query", 400, "limit is 1 to 500");
|
|
64
|
+
query.set("limit", String(options.limit));
|
|
65
|
+
}
|
|
66
|
+
if (options.q !== undefined && options.q !== "") query.set("q", options.q);
|
|
67
|
+
if (options.role !== undefined) query.set("role", options.role);
|
|
68
|
+
if (options.group !== undefined) {
|
|
69
|
+
if (!groupIdPattern.test(options.group)) throw new ChestError("invalid_query", 400, "invalid group identifier");
|
|
70
|
+
query.set("group", options.group);
|
|
71
|
+
}
|
|
72
|
+
const response = await ask("members", "GET", "/members" + (query.size ? "?" + query.toString() : ""));
|
|
73
|
+
if (response.status !== 200) throw await refusal(response, "members");
|
|
74
|
+
const page = (await json(response)) as { members?: unknown; next?: unknown } | null;
|
|
75
|
+
if (!page || !Array.isArray(page.members) || page.members.length > maxLimit || !(page.next === null || text(page.next, 1024))) throw new Unavailable();
|
|
76
|
+
return { members: page.members.map(shown), next: page.next };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// get is the member of that identifier, or null when the tool does not know
|
|
80
|
+
// them: never a member, a former one, or one without access.
|
|
81
|
+
export async function get(id: string): Promise<Member | null> {
|
|
82
|
+
const response = await ask("members", "GET", "/members/" + checkId(id));
|
|
83
|
+
if (response.status === 404) {
|
|
84
|
+
await response.body?.cancel();
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (response.status !== 200) throw await refusal(response, "members");
|
|
88
|
+
return shown(await json(response));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// What lookup keeps in this process: each identifier's answer for a minute,
|
|
92
|
+
// 5000 of them at most, the oldest forgotten first — and nothing once an
|
|
93
|
+
// event of the members' lifecycle comes (events.handle).
|
|
94
|
+
type Known = { at: number } & ({ member: Member } | { former: FormerMember } | { unknown: true });
|
|
95
|
+
const known = new Map<string, Known>();
|
|
96
|
+
|
|
97
|
+
// forget empties what lookup keeps: the next lookup asks the Chest again.
|
|
98
|
+
export function forget(): void {
|
|
99
|
+
known.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function keep(id: string, answer: Omit<Known, "at">): void {
|
|
103
|
+
known.delete(id);
|
|
104
|
+
known.set(id, { ...answer, at: Date.now() } as Known);
|
|
105
|
+
while (known.size > cacheSize) known.delete(known.keys().next().value as string);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// lookup resolves identifiers, each once, in the order given: the members
|
|
109
|
+
// who have the tool, the former members who had it, and the identifiers it
|
|
110
|
+
// does not know. Any number of them: the SDK asks 200 at a time, and keeps
|
|
111
|
+
// each answer a minute.
|
|
112
|
+
export async function lookup(ids: Iterable<string>): Promise<Lookup> {
|
|
113
|
+
const wanted = [...new Set([...ids].map(checkId))];
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const missing = wanted.filter(id => {
|
|
116
|
+
const k = known.get(id);
|
|
117
|
+
return !k || now - k.at >= cacheTime;
|
|
118
|
+
});
|
|
119
|
+
for (let i = 0; i < missing.length; i += lookupBatch) {
|
|
120
|
+
const batch = missing.slice(i, i + lookupBatch);
|
|
121
|
+
const response = await ask("members", "POST", "/members/lookup", { body: JSON.stringify({ ids: batch }), type: "application/json" });
|
|
122
|
+
if (response.status !== 200) throw await refusal(response, "members");
|
|
123
|
+
const answer = (await json(response)) as { members?: unknown; former?: unknown; unknown?: unknown } | null;
|
|
124
|
+
if (!answer || !Array.isArray(answer.members) || !Array.isArray(answer.former) || !Array.isArray(answer.unknown)) throw new Unavailable();
|
|
125
|
+
const told = new Set<string>();
|
|
126
|
+
for (const m of answer.members.map(shown)) {
|
|
127
|
+
keep(m.id, { member: m });
|
|
128
|
+
told.add(m.id);
|
|
129
|
+
}
|
|
130
|
+
for (const value of answer.former) {
|
|
131
|
+
const f = value as { id?: unknown; name?: unknown; status?: unknown } | null;
|
|
132
|
+
if (!f || typeof f.id !== "string" || !memberIdPattern.test(f.id) || !(f.status === "former" ? f.name === undefined || text(f.name, 520) : f.status === "erased" && f.name === undefined)) throw new Unavailable();
|
|
133
|
+
keep(f.id, { former: { id: f.id, name: (f.name as string | undefined) ?? null, status: f.status as FormerMember["status"] } });
|
|
134
|
+
told.add(f.id);
|
|
135
|
+
}
|
|
136
|
+
for (const id of answer.unknown) {
|
|
137
|
+
if (typeof id !== "string" || !memberIdPattern.test(id)) throw new Unavailable();
|
|
138
|
+
keep(id, { unknown: true });
|
|
139
|
+
told.add(id);
|
|
140
|
+
}
|
|
141
|
+
if (batch.some(id => !told.has(id))) throw new Unavailable();
|
|
142
|
+
}
|
|
143
|
+
const result: Lookup = { members: [], former: [], unknown: [] };
|
|
144
|
+
for (const id of wanted) {
|
|
145
|
+
const k = known.get(id);
|
|
146
|
+
if (k && "member" in k) result.members.push(k.member);
|
|
147
|
+
else if (k && "former" in k) result.former.push(k.former);
|
|
148
|
+
else result.unknown.push(id);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// groups are the groups of the Chest that give the tool, each with the
|
|
154
|
+
// identifiers of its members; nothing of the others.
|
|
155
|
+
export const groups = {
|
|
156
|
+
async list(): Promise<Group[]> {
|
|
157
|
+
const response = await ask("members", "GET", "/groups");
|
|
158
|
+
if (response.status !== 200) throw await refusal(response, "members");
|
|
159
|
+
const answer = (await json(response)) as { groups?: unknown } | null;
|
|
160
|
+
if (!answer || !Array.isArray(answer.groups) || answer.groups.length > 16) throw new Unavailable();
|
|
161
|
+
return answer.groups.map(value => {
|
|
162
|
+
const g = value as { id?: unknown; name?: unknown; members?: unknown } | null;
|
|
163
|
+
if (!g || typeof g.id !== "string" || !groupIdPattern.test(g.id) || !text(g.name, 256) || !Array.isArray(g.members) || g.members.length > 128 || !g.members.every(m => typeof m === "string" && memberIdPattern.test(m))) throw new Unavailable();
|
|
164
|
+
return { id: g.id, name: g.name, members: [...g.members] as string[] };
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ask as chest, json, refusal } from "./api.js";
|
|
2
|
+
import { ChestError, Unavailable } from "./errors.js";
|
|
3
|
+
import { memberIdPattern } from "./member.js";
|
|
4
|
+
|
|
5
|
+
// Counters and notifications inside the Chest, for a server tool whose
|
|
6
|
+
// chest.json declares "capabilities": ["notifications"]: a badge is a count
|
|
7
|
+
// shown on the tool's tile for one member; a notification is an item in a
|
|
8
|
+
// member's inbox that opens a page of the tool. Only members who have access
|
|
9
|
+
// to the tool receive them; nothing leaves the Chest (no email, no push).
|
|
10
|
+
//
|
|
11
|
+
// import * as notifications from "@argentic/chest-sdk/notifications";
|
|
12
|
+
// const { delivered, skipped } = await notifications.notify(ids, { title: "New task", path: "/chest/tasks/42", key: "task:42" });
|
|
13
|
+
// await notifications.withdraw("task:42"); // the task is done: its items go
|
|
14
|
+
// const shown = await notifications.badge.set("mbr_…", 3); // false: no access
|
|
15
|
+
// await notifications.badge.setMany([{ memberId: "mbr_…", count: 0 }]);
|
|
16
|
+
//
|
|
17
|
+
// Text is plain: the Chest removes control characters, interprets neither
|
|
18
|
+
// Markdown nor HTML, keeps line breaks in body. A member who muted the tool
|
|
19
|
+
// counts as delivered: the tool never learns it. Errors: CapabilityNotGranted
|
|
20
|
+
// (403), QuotaExceeded (429: 1,000 recipients an hour, 100 items per member a
|
|
21
|
+
// day, 600 badge writes a minute), Unavailable (503, or the Chest not
|
|
22
|
+
// reached), ChestError otherwise (invalid_id, invalid_title, invalid_text,
|
|
23
|
+
// invalid_path, invalid_key, invalid_count, invalid_body 400).
|
|
24
|
+
|
|
25
|
+
// What a notification says: title (1 to 80 characters), body (280 at most),
|
|
26
|
+
// path (the page of the tool it opens, under /chest; /chest when not said)
|
|
27
|
+
// and key (a name of the tool's: an item of the same key for the same member
|
|
28
|
+
// is replaced, and withdraw removes it).
|
|
29
|
+
export type Notice = { title: string; body?: string; path?: string; key?: string };
|
|
30
|
+
// Who got it and who not, each identifier once in the order given: skipped
|
|
31
|
+
// are identifiers the Chest does not know and members without access.
|
|
32
|
+
export type Delivery = { delivered: string[]; skipped: string[] };
|
|
33
|
+
// A badge to set: a member and their count, 0 to clear it.
|
|
34
|
+
export type BadgeCount = { memberId: string; count: number };
|
|
35
|
+
// The members whose badge was set, and those skipped (no access), in the
|
|
36
|
+
// order given.
|
|
37
|
+
export type BadgeWrite = { set: string[]; skipped: string[] };
|
|
38
|
+
|
|
39
|
+
// The bounds of a Chest.
|
|
40
|
+
const maxMembers = 500, maxTitle = 80, maxBody = 280, maxPath = 512, maxCount = 9999;
|
|
41
|
+
const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
|
|
42
|
+
// What the Chest removes from a title before keeping it: control characters
|
|
43
|
+
// and the characters that reorder text.
|
|
44
|
+
const removed = /[\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
45
|
+
|
|
46
|
+
const ask = (method: string, path: string, value: unknown) => chest("notifications", method, path, { body: JSON.stringify(value), type: "application/json" });
|
|
47
|
+
// expect lets through the one answer expected; a refusal is what the Chest
|
|
48
|
+
// says, any other success is not the Chest's.
|
|
49
|
+
async function expect(response: Response, status: number): Promise<void> {
|
|
50
|
+
if (response.status === status) return;
|
|
51
|
+
if (response.status < 400) {
|
|
52
|
+
await response.body?.cancel();
|
|
53
|
+
throw new Unavailable();
|
|
54
|
+
}
|
|
55
|
+
throw await refusal(response, "notifications");
|
|
56
|
+
}
|
|
57
|
+
const length = (s: string): number => [...s].length;
|
|
58
|
+
|
|
59
|
+
function checkId(id: unknown): string {
|
|
60
|
+
if (typeof id !== "string" || !memberIdPattern.test(id)) throw new ChestError("invalid_id", 400, "invalid member identifier");
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
// checkIds reads 1 to 500 member identifiers, as given.
|
|
64
|
+
function checkIds(ids: Iterable<string>): string[] {
|
|
65
|
+
const all = [...ids];
|
|
66
|
+
if (all.length < 1 || all.length > maxMembers) throw new ChestError("invalid_body", 400, "1 to 500 member identifiers");
|
|
67
|
+
return all.map(checkId);
|
|
68
|
+
}
|
|
69
|
+
function checkKey(key: unknown): string {
|
|
70
|
+
if (typeof key !== "string" || !keyPattern.test(key)) throw new ChestError("invalid_key", 400, "a key is 1 to 64 of a-z 0-9 . _ : -");
|
|
71
|
+
return key;
|
|
72
|
+
}
|
|
73
|
+
function checkCount(count: unknown): number {
|
|
74
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count > maxCount) throw new ChestError("invalid_count", 400, "a count is 0 to 9999");
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
// checkPath accepts /chest, or /chest followed by '/', '?' or '#': printable
|
|
78
|
+
// ASCII but '\', 512 bytes at most, never '//', no '.' or '..' segment
|
|
79
|
+
// (written %2e either).
|
|
80
|
+
function checkPath(path: unknown): string {
|
|
81
|
+
if (typeof path !== "string" || path.length > maxPath || !/^\/chest([/?#][\x21-\x5b\x5d-\x7e]*)?$/u.test(path) || path.includes("//") || path.split(/[?#]/u)[0]!.split("/").some(s => /^(\.|%2e){1,2}$/iu.test(s))) throw new ChestError("invalid_path", 400, "a path is /chest or under it");
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// partition checks an answer that splits the identifiers asked, each once,
|
|
86
|
+
// in their order, between two lists of it; anything else is not the Chest's.
|
|
87
|
+
function partition(answer: unknown, first: string, second: string, asked: string[]): [string[], string[]] {
|
|
88
|
+
const a = answer !== null && typeof answer === "object" && !Array.isArray(answer) ? answer as Record<string, unknown> : null;
|
|
89
|
+
const lists = [a?.[first], a?.[second]];
|
|
90
|
+
if (!lists.every(l => Array.isArray(l) && l.every(id => typeof id === "string"))) throw new Unavailable();
|
|
91
|
+
const [one, two] = lists as [string[], string[]];
|
|
92
|
+
const wanted = [...new Set(asked)];
|
|
93
|
+
const inOrder = (l: string[]) => l.every((id, i) => wanted.indexOf(id) > (i === 0 ? -1 : wanted.indexOf(l[i - 1]!)));
|
|
94
|
+
if (one.length + two.length !== wanted.length || !inOrder(one) || !inOrder(two) || one.some(id => two.includes(id))) throw new Unavailable();
|
|
95
|
+
return [[...one], [...two]];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// notify puts one item in the inbox of each member who has the tool, among
|
|
99
|
+
// 1 to 500 identifiers (each counted once). A link to path, on the tool's
|
|
100
|
+
// team host, opens it. With a key, the member's item of that key is replaced:
|
|
101
|
+
// new text, new time, first and unread again.
|
|
102
|
+
export async function notify(memberIds: Iterable<string>, notice: Notice): Promise<Delivery> {
|
|
103
|
+
const members = checkIds(memberIds);
|
|
104
|
+
const { title, body, path, key } = notice;
|
|
105
|
+
if (typeof title !== "string" || length(title) < 1 || length(title) > maxTitle || title.replace(removed, "").trim() === "") throw new ChestError("invalid_title", 400, "a title is 1 to 80 characters");
|
|
106
|
+
if (body !== undefined && (typeof body !== "string" || length(body) > maxBody)) throw new ChestError("invalid_text", 400, "a body is 280 characters at most");
|
|
107
|
+
const command = { members, title, ...(body ? { body } : {}), ...(path !== undefined ? { path: checkPath(path) } : {}), ...(key !== undefined ? { key: checkKey(key) } : {}) };
|
|
108
|
+
const response = await ask("POST", "/notifications", command);
|
|
109
|
+
await expect(response, 200);
|
|
110
|
+
const [delivered, skipped] = partition(await json(response), "delivered", "skipped", members);
|
|
111
|
+
return { delivered, skipped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// withdraw removes the items of that key, from every member or from those
|
|
115
|
+
// named (1 to 500): the thing they were about is done. It never says what
|
|
116
|
+
// existed.
|
|
117
|
+
export async function withdraw(key: string, memberIds?: Iterable<string>): Promise<void> {
|
|
118
|
+
const command = { key: checkKey(key), ...(memberIds !== undefined ? { members: checkIds(memberIds) } : {}) };
|
|
119
|
+
const response = await ask("POST", "/notifications/withdraw", command);
|
|
120
|
+
await expect(response, 204);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// badge sets the count shown on the tool's tile for a member (0 to 9,999; 0
|
|
124
|
+
// clears it): a state, not an event, so setting it again changes nothing.
|
|
125
|
+
export const badge = {
|
|
126
|
+
// set is true once the member's badge is set, false when they do not have
|
|
127
|
+
// the tool.
|
|
128
|
+
async set(memberId: string, count: number): Promise<boolean> {
|
|
129
|
+
checkId(memberId);
|
|
130
|
+
const response = await ask("PUT", "/badges/" + memberId, { count: checkCount(count) });
|
|
131
|
+
await expect(response, 200);
|
|
132
|
+
return partition(await json(response), "set", "skipped", [memberId])[0].length === 1;
|
|
133
|
+
},
|
|
134
|
+
// setMany sets 1 to 500 badges, a member at most once.
|
|
135
|
+
async setMany(counts: Iterable<BadgeCount>): Promise<BadgeWrite> {
|
|
136
|
+
const all = [...counts];
|
|
137
|
+
if (all.length < 1 || all.length > maxMembers) throw new ChestError("invalid_body", 400, "1 to 500 badges");
|
|
138
|
+
const badges = all.map(b => {
|
|
139
|
+
if (b === null || typeof b !== "object") throw new ChestError("invalid_body", 400, "a badge is {memberId, count}");
|
|
140
|
+
return { member: checkId(b.memberId), count: checkCount(b.count) };
|
|
141
|
+
});
|
|
142
|
+
if (new Set(badges.map(b => b.member)).size !== badges.length) throw new ChestError("invalid_body", 400, "a member at most once");
|
|
143
|
+
const response = await ask("PUT", "/badges", { badges });
|
|
144
|
+
await expect(response, 200);
|
|
145
|
+
const [set, skipped] = partition(await json(response), "set", "skipped", badges.map(b => b.member));
|
|
146
|
+
return { set, skipped };
|
|
147
|
+
},
|
|
148
|
+
};
|