@captiveorigins/payload-plugin-onephylogeny 0.1.0-alpha.1 → 0.1.0-alpha.4

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