@captiveorigins/payload-plugin-onephylogeny 0.1.0-alpha.0 → 0.1.0-alpha.3

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 CHANGED
@@ -3,3 +3,23 @@
3
3
  OnePhylogeny for Payload: immutable reference panels and released results on your own collections, with server-enforced review, safe endpoints and no coordinates.
4
4
 
5
5
  See the [repository](https://github.com/CaptiveOrigins/onephylogeny) for documentation.
6
+
7
+ ## Samples and sequences
8
+
9
+ Pass `records` to add `samples` and `sequences`. A lab's file becomes one of each through one call:
10
+
11
+ ```ts
12
+ import { registerSequence } from '@captiveorigins/payload-plugin-onephylogeny'
13
+
14
+ const { sample, sequence, created } = await registerSequence(payload, {
15
+ tenant, animal, kit, lab, file, // your own record IDs; taxon is read from the animal if you leave it out
16
+ sha256, // computed by your server from the stored bytes
17
+ text, // the file's contents, which must hash to sha256
18
+ marker: 'cytb',
19
+ })
20
+ ```
21
+
22
+ It refuses, in plain words, a file whose text doesn't match its SHA-256, a file with more or fewer than one sequence, characters that aren't DNA bases, a sequence outside the lengths set for its marker, and an animal with no species. Sending the same file for the same kit again changes nothing (`created: false`). It writes with full access, so check first that the caller may submit results for that kit.
23
+
24
+ `assignWaiting(payload, { evaluate })` is the sweep. It tests each waiting sequence against the panel for its marker and species, using the `evaluate` function you pass (OnePhylogeny's `testSample` in a Node job), and records a result pending review. A sequence with no panel stays waiting, and one the panel's checks refuse is marked `not-assessable`, with the reason. Results reach readers only after a reviewer approves them.
25
+
package/dist/index.d.ts CHANGED
@@ -1,4 +1,99 @@
1
- import { Access, Plugin } from 'payload';
1
+ import { Result } from '@captiveorigins/onephylogeny-core';
2
+ import { Access, PayloadRequest, Payload, Where, Plugin } from 'payload';
3
+
4
+ /** Settings for the optional `samples` and `sequences` collections. */
5
+ type RecordsOptions = {
6
+ slugs?: {
7
+ samples?: string;
8
+ sequences?: string;
9
+ };
10
+ /** Host collections for the kit a sample came in and the lab that read it. Left out, the field isn't added. */
11
+ relations?: {
12
+ kit?: string;
13
+ lab?: string;
14
+ };
15
+ /** Who may read samples and sequences, besides managers. Default: managers only. */
16
+ readAccess?: Access;
17
+ /** The markers a sequence may be for, with the lengths accepted. Default: any marker, 100 to 50,000 bases. */
18
+ markers?: Record<string, {
19
+ minLength?: number;
20
+ maxLength?: number;
21
+ }>;
22
+ /** The host's tenant field, filled from `registerSequence`'s `tenant` and copied onto results. Default `tenant`. */
23
+ tenantField?: string;
24
+ };
25
+ /** Reads one FASTA file as one sequence, or says in plain words why it can't. */
26
+ declare function readSingleSequence(text: string, marker: string, markers?: RecordsOptions['markers']): {
27
+ recordId: string;
28
+ residues: string;
29
+ };
30
+ type RegisterSequenceInput = {
31
+ /** The host's tenant (site), written to the tenant field. */
32
+ tenant?: string | number;
33
+ animal: string | number;
34
+ /** Taken from the animal's `taxon` field when left out. */
35
+ taxon?: string | number;
36
+ kit?: string | number;
37
+ lab?: string | number;
38
+ /** The host's file record holding the bytes the lab sent. */
39
+ file?: string | number;
40
+ /** SHA-256 of the file, as the host computed it from the stored bytes. */
41
+ sha256: string;
42
+ /** The file's text. It must hash to `sha256`. */
43
+ text: string;
44
+ marker: string;
45
+ source?: 'kit' | 'upload' | 'historic';
46
+ collectedAt?: string;
47
+ req?: PayloadRequest;
48
+ };
49
+ /**
50
+ * Records one lab file as a sample and a sequence. It writes with full access, so the caller must already have checked
51
+ * that this person may submit results for this kit. Calling it again with the same kit and file changes nothing.
52
+ */
53
+ declare function registerSequence(payload: Payload, input: RegisterSequenceInput): Promise<{
54
+ sample: Record<string, unknown>;
55
+ sequence: Record<string, unknown>;
56
+ created: boolean;
57
+ }>;
58
+ /** What `assignWaiting` asks the host to compute for one sequence against one panel. */
59
+ type Evaluate = (args: {
60
+ residues: string;
61
+ queryId: string;
62
+ panel: Record<string, unknown>;
63
+ }) => Promise<{
64
+ result: Result | null;
65
+ problems: string[];
66
+ }>;
67
+ /** A short, plain summary of a result, naming the groups it is consistent with. */
68
+ declare function summarize(result: Result, panel: {
69
+ groups?: {
70
+ groupId: string;
71
+ label: string;
72
+ }[];
73
+ }): string;
74
+ /**
75
+ * The sweep: tests each sequence still `received` against the panel for its marker and species, and records a result
76
+ * waiting for review. A sequence with no panel stays waiting; one the checks refuse is marked not assessable, with why.
77
+ */
78
+ declare function assignWaiting(payload: Payload, args: {
79
+ evaluate: Evaluate;
80
+ limit?: number;
81
+ req?: PayloadRequest;
82
+ }): Promise<{
83
+ checked: number;
84
+ assigned: number;
85
+ notAssessable: number;
86
+ noPanel: number;
87
+ }>;
88
+
89
+ type Granted = boolean | Where;
90
+ /**
91
+ * Joins access results with "or". A rule may grant everything (`true`), nothing, or only matching records (a query), and
92
+ * a query is never read as everything: a reviewer of one site sees that site's records, not all of them.
93
+ */
94
+ declare function either(...grants: (Granted | null | undefined)[]): Granted;
95
+ /** Access that allows what any of the given rules allows. */
96
+ declare const anyOf: (...rules: (Access | undefined)[]) => Access;
2
97
 
3
98
  type OnePhylogenyPluginOptions = {
4
99
  /** Slugs of the collections this plugin adds. */
@@ -14,7 +109,7 @@ type OnePhylogenyPluginOptions = {
14
109
  files?: string;
15
110
  importRuns?: string;
16
111
  };
17
- /** Who may create panels and results, and activate panels. Default: nobody but trusted local scripts. */
112
+ /** Who may create panels and results, and activate panels. Default: nobody but trusted local scripts. Activating also needs a signed-in user, who is recorded. */
18
113
  manageAccess?: Access;
19
114
  /** Who may approve or reject results. Default: same as manageAccess. */
20
115
  reviewAccess?: Access;
@@ -22,11 +117,22 @@ type OnePhylogenyPluginOptions = {
22
117
  readReleasedAccess?: Access;
23
118
  /** Field added to assignments for multi-tenant hosts' isolation plugins to find. */
24
119
  group?: string;
120
+ /**
121
+ * Which panels results may be made against. `activeOrShown` also allows a `candidate` a maintainer ticked "shown as
122
+ * exploratory", and lets readers see such candidates. Default: `active`.
123
+ */
124
+ resultsAgainst?: 'active' | 'activeOrShown';
125
+ /**
126
+ * Adds `samples` and `sequences`, with `registerSequence` to record a lab's file and `assignWaiting` to test waiting
127
+ * sequences. Left out, neither collection is added. Results then relate to these sequences.
128
+ */
129
+ records?: RecordsOptions;
25
130
  };
131
+
26
132
  /**
27
133
  * OnePhylogeny for Payload. Adds immutable panels and reviewed results, relating to whatever collections a site already
28
134
  * has. It holds no species knowledge. Compute happens elsewhere: this stores what was computed and controls release.
29
135
  */
30
136
  declare const onePhylogeny: (options?: OnePhylogenyPluginOptions) => Plugin;
31
137
 
32
- export { type OnePhylogenyPluginOptions, onePhylogeny };
138
+ export { type Evaluate, type OnePhylogenyPluginOptions, type RecordsOptions, type RegisterSequenceInput, anyOf, assignWaiting, either, onePhylogeny, readSingleSequence, registerSequence, summarize };
package/dist/index.js CHANGED
@@ -1,6 +1,216 @@
1
1
  // src/index.ts
2
2
  import { validateResult } from "@captiveorigins/onephylogeny-core";
3
+ import { relationship as validateRelationship } from "payload/shared";
4
+
5
+ // src/access.ts
6
+ function either(...grants) {
7
+ if (grants.some((g) => g === true)) return true;
8
+ const queries = grants.filter((g) => Boolean(g) && typeof g === "object");
9
+ return queries.length === 0 ? false : queries.length === 1 ? queries[0] : { or: queries };
10
+ }
11
+ var anyOf = (...rules) => async (args) => either(...await Promise.all(rules.map((r) => r ? r(args) : false)));
12
+
13
+ // src/records.ts
14
+ import { parseFasta, sha256Hex } from "@captiveorigins/onephylogeny-core";
3
15
  import { APIError } from "payload";
16
+ var CUSTOM_KEY = "onePhylogenyRecords";
17
+ var DNA = /^[ACGTRYKMSWBDHVN]+$/;
18
+ var DEFAULT_LENGTH = { minLength: 100, maxLength: 5e4 };
19
+ var fail = (message) => {
20
+ throw new APIError(message, 400, void 0, true);
21
+ };
22
+ var idOf = (v) => v && typeof v === "object" ? String(v.id) : v == null ? void 0 : String(v);
23
+ var relation = (name, relationTo, extra = {}) => relationTo ? [{ name, type: "relationship", relationTo, index: true, ...extra }] : [];
24
+ var locked = (fields, message) => ({ data, originalDoc, operation }) => {
25
+ if (operation === "update") {
26
+ for (const f of fields) if (f in data && JSON.stringify(idOf(data[f]) ?? data[f] ?? null) !== JSON.stringify(idOf(originalDoc[f]) ?? originalDoc[f] ?? null)) fail(message);
27
+ }
28
+ return data;
29
+ };
30
+ function recordCollections(o, base) {
31
+ const samples = o.slugs?.samples ?? "samples";
32
+ const sequences = o.slugs?.sequences ?? "sequences";
33
+ const manage = base.manage;
34
+ const read = anyOf(manage, o.readAccess);
35
+ const access = { read, create: manage, update: manage, delete: () => false };
36
+ const sampleCollection = {
37
+ slug: samples,
38
+ labels: { singular: "Sample", plural: "Samples" },
39
+ admin: { group: "Genetics", defaultColumns: ["subject", "source", "kit", "createdAt"] },
40
+ access,
41
+ fields: [
42
+ ...relation("subject", base.subject, { required: true }),
43
+ ...relation("kit", o.relations?.kit),
44
+ ...relation("taxon", base.taxa),
45
+ { name: "source", type: "select", required: true, defaultValue: "kit", options: ["kit", "upload", "historic"] },
46
+ { name: "material", type: "text", admin: { description: "What was sampled, such as a shell scrape or blood." } },
47
+ { name: "collectedAt", type: "date" },
48
+ { name: "consent", type: "text", admin: { description: "The keeper's consent, as recorded with the kit or upload." } }
49
+ ],
50
+ hooks: { beforeChange: [locked(["subject", "kit", "source"], "A sample's animal, kit and source can't change. Record a new sample instead.")] }
51
+ };
52
+ const sequenceCollection = {
53
+ slug: sequences,
54
+ labels: { singular: "Sequence", plural: "Sequences" },
55
+ admin: { group: "Genetics", useAsTitle: "recordId", defaultColumns: ["recordId", "marker", "status", "length", "createdAt"] },
56
+ access,
57
+ fields: [
58
+ { name: "sample", type: "relationship", relationTo: samples, required: true, index: true },
59
+ { name: "marker", type: "text", required: true, index: true },
60
+ ...relation("lab", o.relations?.lab),
61
+ ...relation("file", base.files, { admin: { description: "The file the lab sent. It stays the record of what was received." } }),
62
+ { name: "sha256", type: "text", required: true, index: true, admin: { description: "SHA-256 of the file, checked against its text when it was registered." } },
63
+ { name: "recordId", type: "text", admin: { description: "The FASTA header the lab gave this sequence." } },
64
+ { name: "residues", type: "textarea", required: true, admin: { description: "The bases, cleaned of gaps and line breaks. Used for analysis." } },
65
+ { name: "length", type: "number", required: true },
66
+ { name: "status", type: "select", required: true, defaultValue: "received", index: true, options: ["received", "assigned", "not-assessable"] },
67
+ { name: "statusReason", type: "text", admin: { description: "Why it couldn't be assessed, in plain words." } }
68
+ ],
69
+ hooks: { beforeChange: [locked(["sample", "marker", "file", "sha256", "residues", "recordId", "length"], "A sequence can't be edited. Register the corrected file instead.")] }
70
+ };
71
+ const config = {
72
+ samples,
73
+ sequences,
74
+ panels: base.panels,
75
+ assignments: base.assignments,
76
+ tenantField: o.tenantField ?? "tenant",
77
+ markers: o.markers,
78
+ subject: base.subject,
79
+ hasTaxa: Boolean(base.taxa),
80
+ hasKit: Boolean(o.relations?.kit),
81
+ hasLab: Boolean(o.relations?.lab),
82
+ hasFiles: Boolean(base.files),
83
+ againstShown: base.againstShown
84
+ };
85
+ return { collections: [sampleCollection, sequenceCollection], config, customKey: CUSTOM_KEY };
86
+ }
87
+ var configOf = (payload) => {
88
+ const c = payload.config.custom?.[CUSTOM_KEY];
89
+ if (!c) throw new Error("OnePhylogeny samples and sequences aren't turned on. Pass `records` to the plugin.");
90
+ return c;
91
+ };
92
+ function readSingleSequence(text, marker, markers) {
93
+ if (markers && !markers[marker]) fail(`This site doesn't test the marker "${marker}".`);
94
+ const { minLength, maxLength } = { ...DEFAULT_LENGTH, ...markers?.[marker] };
95
+ let entries;
96
+ try {
97
+ entries = parseFasta(text.replace(/^/, ""));
98
+ } catch {
99
+ return fail(`This file isn't FASTA: it has bases before the first ">" header line.`);
100
+ }
101
+ if (!entries.length) fail(`This file is empty or has no ">" header line, so it isn't FASTA.`);
102
+ if (entries.length > 1) fail(`This file holds ${entries.length} sequences. Send one file per kit, with one sequence in it.`);
103
+ const entry = entries[0];
104
+ const residues = entry.sequence.replace(/[\s\-.]/g, "").toUpperCase();
105
+ if (!residues) fail("This file's sequence is empty.");
106
+ if (!DNA.test(residues)) {
107
+ const bad = [...new Set(residues.replace(/[ACGTRYKMSWBDHVN]/g, ""))].slice(0, 5).join(" ");
108
+ fail(`This sequence has characters that aren't DNA bases: ${bad}.`);
109
+ }
110
+ if (residues.length < minLength) fail(`This sequence is ${residues.length} bases long, shorter than the ${minLength} needed for ${marker}.`);
111
+ if (residues.length > maxLength) fail(`This sequence is ${residues.length} bases long, longer than the ${maxLength} expected for ${marker}.`);
112
+ const unresolved = (residues.match(/[^ACGT]/g) ?? []).length;
113
+ if (unresolved > residues.length / 2) fail(`More than half of this sequence's bases are unresolved (${unresolved} of ${residues.length}).`);
114
+ return { recordId: entry.id, residues };
115
+ }
116
+ async function registerSequence(payload, input) {
117
+ const c = configOf(payload);
118
+ const opts = { req: input.req, overrideAccess: true, depth: 0 };
119
+ const sha256 = input.sha256.replace(/^sha256:/i, "").toLowerCase();
120
+ if (!/^[0-9a-f]{64}$/.test(sha256)) fail("The file's SHA-256 is missing or malformed.");
121
+ if (sha256Hex(input.text) !== sha256) fail("This file's text doesn't match its SHA-256. It may have changed after upload; upload it again.");
122
+ if ((input.source ?? "kit") === "kit" && input.kit == null) fail("A kit sample needs its kit.");
123
+ if (input.animal == null || input.animal === "") fail("This kit isn't linked to an animal yet. It has to be activated first.");
124
+ const { recordId, residues } = readSingleSequence(input.text, input.marker, c.markers);
125
+ let taxon = input.taxon;
126
+ if (taxon == null && c.hasTaxa) {
127
+ const subject = c.subject ? await payload.findByID({ collection: c.subject, id: input.animal, ...opts }).catch(() => null) : null;
128
+ taxon = idOf(subject?.taxon);
129
+ }
130
+ if (c.hasTaxa && taxon == null) fail("This animal has no species recorded, so its sequence can't be matched to a panel.");
131
+ let sample;
132
+ if (input.kit != null && c.hasKit) {
133
+ sample = (await payload.find({ collection: c.samples, where: { kit: { equals: input.kit } }, limit: 1, ...opts })).docs[0];
134
+ if (sample && idOf(sample.subject) !== String(input.animal)) fail("This kit is already recorded for another animal.");
135
+ }
136
+ if (sample) {
137
+ const existing = (await payload.find({ collection: c.sequences, where: { and: [{ sample: { equals: sample.id } }, { sha256: { equals: sha256 } }] }, limit: 1, ...opts })).docs[0];
138
+ if (existing) return { sample, sequence: existing, created: false };
139
+ } else {
140
+ sample = await payload.create({ collection: c.samples, data: {
141
+ ...c.subject ? { subject: input.animal } : {},
142
+ ...c.hasKit && input.kit != null ? { kit: input.kit } : {},
143
+ ...c.hasTaxa ? { taxon } : {},
144
+ source: input.source ?? "kit",
145
+ ...input.collectedAt ? { collectedAt: input.collectedAt } : {},
146
+ ...input.tenant != null ? { [c.tenantField]: input.tenant } : {}
147
+ }, ...opts });
148
+ }
149
+ const sequence = await payload.create({ collection: c.sequences, data: {
150
+ sample: sample.id,
151
+ marker: input.marker,
152
+ sha256,
153
+ recordId,
154
+ residues,
155
+ length: residues.length,
156
+ status: "received",
157
+ ...c.hasLab && input.lab != null ? { lab: input.lab } : {},
158
+ ...c.hasFiles && input.file != null ? { file: input.file } : {},
159
+ ...input.tenant != null ? { [c.tenantField]: input.tenant } : {}
160
+ }, ...opts });
161
+ return { sample, sequence, created: true };
162
+ }
163
+ function summarize(result, panel) {
164
+ const label = (id) => panel.groups?.find((g) => g.groupId === id)?.label ?? id;
165
+ const consistent = result.findings.filter((f) => f.status === "consistent").map((f) => label(f.group));
166
+ if (consistent.length === 1) return `Consistent with ${consistent[0]}`;
167
+ if (consistent.length > 1) return `Consistent with ${consistent.slice(0, -1).join(", ")} or ${consistent.at(-1)}`;
168
+ return "No single lineage";
169
+ }
170
+ async function assignWaiting(payload, args) {
171
+ const c = configOf(payload);
172
+ const opts = { req: args.req, overrideAccess: true, depth: 0 };
173
+ const tally = { assigned: 0, notAssessable: 0, noPanel: 0 };
174
+ const waiting = await payload.find({ collection: c.sequences, where: { status: { equals: "received" } }, sort: "createdAt", limit: args.limit ?? 200, ...opts });
175
+ const usable = c.againstShown ? { or: [{ status: { equals: "active" } }, { and: [{ status: { equals: "candidate" } }, { shownAsExploratory: { equals: true } }] }] } : { status: { equals: "active" } };
176
+ for (const seq of waiting.docs) {
177
+ const sample = await payload.findByID({ collection: c.samples, id: idOf(seq.sample), ...opts });
178
+ const taxon = idOf(sample.taxon);
179
+ const found = await payload.find({ collection: c.panels, where: { and: [{ marker: { equals: seq.marker } }, usable] }, sort: "-createdAt", limit: 50, ...opts });
180
+ const fits = found.docs.filter((p) => !c.hasTaxa || !p.taxa?.length || p.taxa.map(idOf).includes(taxon));
181
+ const panel = fits.find((p) => p.status === "active") ?? fits[0];
182
+ if (!panel) {
183
+ tally.noPanel++;
184
+ continue;
185
+ }
186
+ const already = await payload.find({ collection: c.assignments, where: { and: [{ sequence: { equals: seq.id } }, { panel: { equals: panel.id } }] }, limit: 1, ...opts });
187
+ if (already.docs.length) {
188
+ await payload.update({ collection: c.sequences, id: seq.id, data: { status: "assigned" }, ...opts });
189
+ continue;
190
+ }
191
+ const { result, problems } = await args.evaluate({ residues: String(seq.residues), queryId: String(seq.id), panel });
192
+ if (!result) {
193
+ await payload.update({ collection: c.sequences, id: seq.id, data: { status: "not-assessable", statusReason: (problems.join("; ") || "The panel's checks refused this sequence.").slice(0, 1e3) }, ...opts });
194
+ tally.notAssessable++;
195
+ continue;
196
+ }
197
+ const tenant = sample[c.tenantField];
198
+ await payload.create({ collection: c.assignments, data: {
199
+ ...c.subject ? { subject: idOf(sample.subject) } : {},
200
+ sequence: seq.id,
201
+ panel: panel.id,
202
+ summary: summarize(result, panel),
203
+ result,
204
+ ...tenant != null ? { [c.tenantField]: idOf(tenant) } : {}
205
+ }, ...opts });
206
+ await payload.update({ collection: c.sequences, id: seq.id, data: { status: "assigned" }, ...opts });
207
+ tally.assigned++;
208
+ }
209
+ return { ...tally, checked: waiting.docs.length };
210
+ }
211
+
212
+ // src/index.ts
213
+ import { APIError as APIError2 } from "payload";
4
214
  var PANEL_TRANSITIONS = { candidate: ["active", "withdrawn"], active: ["superseded", "withdrawn"], superseded: ["withdrawn"], withdrawn: [] };
5
215
  var LOCKED_PANEL_FIELDS = ["key", "version", "marker", "checksum", "artifact", "importRun", "taxa", "groups", "notCovered", "validatedScope"];
6
216
  var REVIEW_TRANSITIONS = { pending: ["approved", "rejected"], approved: ["withdrawn"], rejected: [], withdrawn: [] };
@@ -10,30 +220,45 @@ var hasCoordinates = (value) => {
10
220
  if (!value || typeof value !== "object") return false;
11
221
  return Object.entries(value).some(([k, v]) => COORDINATES.test(k) || hasCoordinates(v));
12
222
  };
13
- var fail = (message) => {
14
- throw new APIError(message, 400, void 0, true);
223
+ var fail2 = (message) => {
224
+ throw new APIError2(message, 400, void 0, true);
15
225
  };
16
- var idOf = (v) => v && typeof v === "object" ? String(v.id) : v == null ? void 0 : String(v);
226
+ var idOf2 = (v) => v && typeof v === "object" ? String(v.id) : v == null ? void 0 : String(v);
17
227
  var same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
18
228
  var stripIds = (rows) => Array.isArray(rows) ? rows.map((r) => {
19
229
  const { id: _id, ...rest } = r;
20
230
  return rest;
21
231
  }) : rows;
22
- var panelBeforeChange = ({ data, originalDoc, operation }) => {
23
- if (hasCoordinates(data.groups)) fail("Panels use generalized regions and never hold coordinates.");
232
+ var stamp = (v) => v == null || v === "" ? "" : v instanceof Date ? v.toISOString() : String(v);
233
+ var ACTIVATION_FIELDS = ["activatedBy", "activatedAt"];
234
+ var panelBeforeChange = ({ data, originalDoc, operation, req }) => {
235
+ const leaving = operation === "update" && String(data.status ?? originalDoc.status) !== "candidate";
236
+ if (leaving) {
237
+ if (data.shownAsExploratory === true && String(originalDoc.status) !== "candidate") fail2("Only a candidate panel can be shown as exploratory.");
238
+ data.shownAsExploratory = false;
239
+ }
240
+ if (hasCoordinates(data.groups)) fail2("Panels use generalized regions and never hold coordinates.");
24
241
  if (operation === "create") {
25
- if ((data.status ?? "candidate") !== "candidate") fail("A new panel starts as a candidate.");
242
+ if ((data.status ?? "candidate") !== "candidate") fail2("A new panel starts as a candidate.");
243
+ if (ACTIVATION_FIELDS.some((f) => stamp(data[f]))) fail2("Who activated a panel, and when, are set by activating.");
26
244
  return data;
27
245
  }
28
246
  const from = String(originalDoc.status);
29
247
  const to = String(data.status ?? from);
30
- if (to !== from && !PANEL_TRANSITIONS[from]?.includes(to)) fail(`A panel can't go from ${from} to ${to}.`);
31
- if (from === "candidate" && to === "active" && !(data.validatedScope ?? originalDoc.validatedScope)?.length) fail("A panel with no validated claims can't become active.");
248
+ if (to !== from && !PANEL_TRANSITIONS[from]?.includes(to)) fail2(`A panel can't go from ${from} to ${to}.`);
249
+ if (from === "candidate" && to === "active") {
250
+ if (!(data.validatedScope ?? originalDoc.validatedScope)?.length) fail2("A panel with no validated claims can't become active.");
251
+ if (!req.user) fail2("Activating a panel needs a signed-in person, who is recorded.");
252
+ data.activatedBy = String(req.user.id);
253
+ data.activatedAt = (/* @__PURE__ */ new Date()).toISOString();
254
+ } else if (ACTIVATION_FIELDS.some((f) => f in data && stamp(data[f]) !== stamp(originalDoc[f]))) {
255
+ fail2("Who activated a panel, and when, are set by activating.");
256
+ }
32
257
  if (from !== "candidate") {
33
258
  for (const f of LOCKED_PANEL_FIELDS) {
34
259
  if (!(f in data)) continue;
35
- const changed = f === "groups" ? !same(stripIds(data[f]), stripIds(originalDoc[f])) : Array.isArray(data[f]) ? !same(data[f].map(idOf), (originalDoc[f] ?? []).map(idOf)) : idOf(data[f]) !== idOf(originalDoc[f]);
36
- if (changed) fail(`A ${from} panel can't be edited. Build a new version instead.`);
260
+ const changed = f === "groups" ? !same(stripIds(data[f]), stripIds(originalDoc[f])) : Array.isArray(data[f]) ? !same(data[f].map(idOf2), (originalDoc[f] ?? []).map(idOf2)) : idOf2(data[f]) !== idOf2(originalDoc[f]);
261
+ if (changed) fail2(`A ${from} panel can't be edited. Build a new version instead.`);
37
262
  }
38
263
  }
39
264
  return data;
@@ -44,56 +269,66 @@ var supersedeOthers = (panels) => async ({ doc, previousDoc, req }) => {
44
269
  for (const other of others.docs) await req.payload.update({ collection: panels, id: other.id, data: { status: "superseded" }, req, overrideAccess: true });
45
270
  return doc;
46
271
  };
47
- var assignmentBeforeChange = (panels) => async ({ data, originalDoc, operation, req }) => {
272
+ var assignmentBeforeChange = (panels, againstShown) => async ({ data, originalDoc, operation, req }) => {
48
273
  if (operation === "create") {
49
- if ((data.reviewStatus ?? "pending") !== "pending") fail("A new result starts as pending review.");
50
- const panel = await req.payload.findByID({ collection: panels, id: idOf(data.panel), depth: 0, req, overrideAccess: true });
51
- if (panel.status !== "active") fail("Results can only be made against an active panel.");
274
+ if ((data.reviewStatus ?? "pending") !== "pending") fail2("A new result starts as pending review.");
275
+ const panel = await req.payload.findByID({ collection: panels, id: idOf2(data.panel), depth: 0, req, overrideAccess: true });
276
+ const shown = panel.status === "candidate" && panel.shownAsExploratory === true;
277
+ if (panel.status !== "active" && !(againstShown && shown)) fail2(againstShown ? "Results can only be made against an active panel, or a candidate shown as exploratory." : "Results can only be made against an active panel.");
278
+ data.panelStatus = panel.status;
279
+ data.panelShownAsExploratory = shown;
52
280
  const result = data.result;
53
281
  const problems = validateResult(result).filter((d) => d.severity === "error");
54
- if (problems.length) fail(problems.map((p) => p.message).join("; "));
55
- if (result.panel.version !== panel.version || result.panel.checksum !== panel.checksum) fail("This result was not made with this panel version.");
282
+ if (problems.length) fail2(problems.map((p) => p.message).join("; "));
283
+ if (result.panel.version !== panel.version || result.panel.checksum !== panel.checksum) fail2("This result was not made with this panel version.");
56
284
  const found = new Set(result.findings.map((f) => f.group));
57
285
  const missing = (panel.groups ?? []).filter((g) => !found.has(g.groupId)).map((g) => g.groupId);
58
- if (missing.length) fail(`Every group must be reported. Missing: ${missing.join(", ")}.`);
59
- if (hasCoordinates(result)) fail("Results never hold coordinates.");
286
+ if (missing.length) fail2(`Every group must be reported. Missing: ${missing.join(", ")}.`);
287
+ if (hasCoordinates(result)) fail2("Results never hold coordinates.");
60
288
  return data;
61
289
  }
62
290
  const from = String(originalDoc.reviewStatus);
63
291
  const to = String(data.reviewStatus ?? from);
64
292
  for (const f of ["result", "panel", "sequence", "subject", "summary", "importRun"]) {
65
- if (f in data && (f === "result" || f === "summary" ? !same(data[f], originalDoc[f]) : idOf(data[f]) !== idOf(originalDoc[f]))) fail("A result can't be edited. Make a new one instead.");
293
+ if (f in data && (f === "result" || f === "summary" ? !same(data[f], originalDoc[f]) : idOf2(data[f]) !== idOf2(originalDoc[f]))) fail2("A result can't be edited. Make a new one instead.");
294
+ }
295
+ for (const f of ["panelStatus", "panelShownAsExploratory"]) {
296
+ if (f in data && !same(data[f], originalDoc[f])) fail2("The panel status a result was made against is set when it is made.");
66
297
  }
67
298
  if (to !== from) {
68
- if (!REVIEW_TRANSITIONS[from]?.includes(to)) fail(`A result can't go from ${from} to ${to}.`);
299
+ if (!REVIEW_TRANSITIONS[from]?.includes(to)) fail2(`A result can't go from ${from} to ${to}.`);
69
300
  if (to === "approved") {
70
- if (!req.user) fail("Approving a result needs a signed-in reviewer.");
301
+ if (!req.user) fail2("Approving a result needs a signed-in reviewer.");
71
302
  data.reviewer = String(req.user.id);
72
303
  data.releasedAt = (/* @__PURE__ */ new Date()).toISOString();
73
304
  }
74
305
  } else {
75
- if ("reviewer" in data && data.reviewer !== originalDoc.reviewer) fail("The reviewer is set by approving.");
76
- if ("releasedAt" in data && String(data.releasedAt) !== String(originalDoc.releasedAt)) fail("The release date is set by approving.");
306
+ if ("reviewer" in data && data.reviewer !== originalDoc.reviewer) fail2("The reviewer is set by approving.");
307
+ if ("releasedAt" in data && String(data.releasedAt) !== String(originalDoc.releasedAt)) fail2("The release date is set by approving.");
77
308
  }
78
309
  return data;
79
310
  };
80
- var relation = (name, relationTo, extra = {}) => relationTo ? [{ name, type: "relationship", relationTo, index: true, ...extra }] : [];
311
+ var relation2 = (name, relationTo, extra = {}) => relationTo ? [{ name, type: "relationship", relationTo, index: true, ...extra }] : [];
312
+ var checkedWhenMade = { validate: (value, options) => options.operation === "update" ? true : validateRelationship(value, options) };
81
313
  var onePhylogeny = (options = {}) => (config) => {
82
314
  const panels = options.slugs?.panels ?? "panels";
83
315
  const assignments = options.slugs?.assignments ?? "assignments";
84
- const r = options.relations ?? {};
316
+ const records = options.records ? recordCollections(options.records, { panels, assignments, subject: options.relations?.subject, taxa: options.relations?.taxa, files: options.relations?.files, manage: options.manageAccess ?? (() => false), againstShown: options.resultsAgainst === "activeOrShown" }) : void 0;
317
+ const r = { ...options.relations, ...records && !options.relations?.sequence ? { sequence: records.config.sequences } : {} };
85
318
  const group = options.group ?? "Genetics";
86
319
  const manage = options.manageAccess ?? (() => false);
87
320
  const review = options.reviewAccess ?? manage;
88
321
  const readReleased = options.readReleasedAccess ?? (({ req }) => Boolean(req.user));
322
+ const againstShown = options.resultsAgainst === "activeOrShown";
89
323
  const panelCollection = {
90
324
  slug: panels,
91
325
  labels: { singular: "Reference panel", plural: "Reference panels" },
92
326
  admin: { group, useAsTitle: "key", defaultColumns: ["key", "version", "status", "marker"] },
93
327
  access: {
94
328
  read: async (args) => {
95
- if (await manage(args)) return true;
96
- return { status: { in: ["active", "superseded"] } };
329
+ const published = { status: { in: ["active", "superseded"] } };
330
+ const shown = { and: [{ status: { equals: "candidate" } }, { shownAsExploratory: { equals: true } }] };
331
+ return either(await manage(args), published, againstShown ? shown : false);
97
332
  },
98
333
  create: manage,
99
334
  update: manage,
@@ -103,14 +338,17 @@ var onePhylogeny = (options = {}) => (config) => {
103
338
  { name: "key", type: "text", required: true, index: true, admin: { description: "Stays the same across versions of one panel." } },
104
339
  { name: "version", type: "text", required: true },
105
340
  { name: "status", type: "select", required: true, defaultValue: "candidate", index: true, options: ["candidate", "active", "superseded", "withdrawn"] },
341
+ { name: "shownAsExploratory", type: "checkbox", defaultValue: false, index: true, admin: { description: "Show this candidate as exploratory, before review. Only candidates; cleared when it moves on." } },
106
342
  { name: "marker", type: "text", required: true },
107
343
  { name: "checksum", type: "text", required: true, index: true },
108
- ...relation("taxa", r.taxa, { hasMany: true }),
109
- ...relation("artifact", r.files, { admin: { description: "The full panel: JSON manifest and sequences." } }),
110
- ...relation("importRun", r.importRuns),
344
+ ...relation2("taxa", r.taxa, { hasMany: true }),
345
+ ...relation2("artifact", r.files, { admin: { description: "The full panel: JSON manifest and sequences." } }),
346
+ ...relation2("importRun", r.importRuns),
111
347
  { name: "validatedScope", type: "text", hasMany: true, admin: { description: "The claims this panel is validated for. Pages show only these." } },
112
348
  { name: "notCovered", type: "text", hasMany: true },
113
- { name: "groups", type: "array", fields: [{ name: "groupId", type: "text", required: true }, { name: "label", type: "text", required: true }, { name: "regionName", type: "text", admin: { description: "A general region, never coordinates." } }] }
349
+ { name: "groups", type: "array", fields: [{ name: "groupId", type: "text", required: true }, { name: "label", type: "text", required: true }, { name: "regionName", type: "text", admin: { description: "A general region, never coordinates." } }] },
350
+ { name: "activatedBy", type: "text", admin: { readOnly: true, description: "The signed-in user who activated this panel. Set by activating." } },
351
+ { name: "activatedAt", type: "date", admin: { readOnly: true } }
114
352
  ],
115
353
  hooks: { beforeChange: [panelBeforeChange], afterChange: [supersedeOthers(panels)] }
116
354
  };
@@ -119,29 +357,30 @@ var onePhylogeny = (options = {}) => (config) => {
119
357
  labels: { singular: "Lineage result", plural: "Lineage results" },
120
358
  admin: { group, useAsTitle: "summary", defaultColumns: ["summary", "panel", "reviewStatus", "releasedAt"] },
121
359
  access: {
360
+ // Managers and reviewers see what their own rules allow, including pending results; everyone else only released ones.
122
361
  read: async (args) => {
123
- if (await manage(args) || await review(args)) return true;
124
- const allowed = await readReleased(args);
125
- if (!allowed) return false;
126
362
  const released2 = { reviewStatus: { equals: "approved" } };
127
- return allowed === true ? released2 : { and: [released2, allowed] };
363
+ const allowed = await readReleased(args);
364
+ return either(await manage(args), await review(args), allowed === true ? released2 : allowed ? { and: [released2, allowed] } : false);
128
365
  },
129
366
  create: manage,
130
367
  update: review,
131
368
  delete: () => false
132
369
  },
133
370
  fields: [
134
- ...relation("subject", r.subject),
135
- ...relation("sequence", r.sequence),
136
- { name: "panel", type: "relationship", relationTo: panels, required: true, index: true },
371
+ ...relation2("subject", r.subject, checkedWhenMade),
372
+ ...relation2("sequence", r.sequence, checkedWhenMade),
373
+ { name: "panel", type: "relationship", relationTo: panels, required: true, index: true, ...checkedWhenMade },
137
374
  { name: "summary", type: "text", required: true },
138
375
  { name: "result", type: "json", required: true, admin: { description: "The OnePhylogeny result, stored exactly as computed." } },
139
- ...relation("importRun", r.importRuns),
376
+ ...relation2("importRun", r.importRuns, checkedWhenMade),
140
377
  { name: "reviewStatus", type: "select", required: true, defaultValue: "pending", index: true, options: ["pending", "approved", "rejected", "withdrawn"] },
141
378
  { name: "reviewer", type: "text", admin: { readOnly: true } },
142
- { name: "releasedAt", type: "date", admin: { readOnly: true } }
379
+ { name: "releasedAt", type: "date", admin: { readOnly: true } },
380
+ { name: "panelStatus", type: "text", admin: { readOnly: true, description: "The panel's status when this result was made." } },
381
+ { name: "panelShownAsExploratory", type: "checkbox", admin: { readOnly: true } }
143
382
  ],
144
- hooks: { beforeChange: [assignmentBeforeChange(panels)] }
383
+ hooks: { beforeChange: [assignmentBeforeChange(panels, againstShown)] }
145
384
  };
146
385
  const released = {
147
386
  path: "/onephylogeny/released",
@@ -153,8 +392,19 @@ var onePhylogeny = (options = {}) => (config) => {
153
392
  return Response.json({ docs: found.docs });
154
393
  }
155
394
  };
156
- return { ...config, collections: [...config.collections ?? [], panelCollection, assignmentCollection], endpoints: [...config.endpoints ?? [], released] };
395
+ return {
396
+ ...config,
397
+ collections: [...config.collections ?? [], ...records?.collections ?? [], panelCollection, assignmentCollection],
398
+ endpoints: [...config.endpoints ?? [], released],
399
+ ...records ? { custom: { ...config.custom, [records.customKey]: records.config } } : {}
400
+ };
157
401
  };
158
402
  export {
159
- onePhylogeny
403
+ anyOf,
404
+ assignWaiting,
405
+ either,
406
+ onePhylogeny,
407
+ readSingleSequence,
408
+ registerSequence,
409
+ summarize
160
410
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@captiveorigins/payload-plugin-onephylogeny",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "OnePhylogeny for Payload: immutable reference panels and released results on your own collections, with server-enforced review, safe endpoints and no coordinates.",
5
5
  "keywords": [
6
6
  "phylogenetics",
@@ -35,7 +35,7 @@
35
35
  "node": ">=22"
36
36
  },
37
37
  "dependencies": {
38
- "@captiveorigins/onephylogeny-core": "^0.1.0-alpha.0"
38
+ "@captiveorigins/onephylogeny-core": "^0.1.0-alpha.3"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "payload": "^3.80.0"