@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 +20 -0
- package/dist/index.d.ts +109 -3
- package/dist/index.js +293 -43
- package/package.json +2 -2
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 {
|
|
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
|
|
14
|
-
throw new
|
|
223
|
+
var fail2 = (message) => {
|
|
224
|
+
throw new APIError2(message, 400, void 0, true);
|
|
15
225
|
};
|
|
16
|
-
var
|
|
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
|
|
23
|
-
|
|
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")
|
|
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))
|
|
31
|
-
if (from === "candidate" && to === "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(
|
|
36
|
-
if (changed)
|
|
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")
|
|
50
|
-
const panel = await req.payload.findByID({ collection: panels, id:
|
|
51
|
-
|
|
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)
|
|
55
|
-
if (result.panel.version !== panel.version || result.panel.checksum !== panel.checksum)
|
|
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)
|
|
59
|
-
if (hasCoordinates(result))
|
|
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]) :
|
|
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))
|
|
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)
|
|
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)
|
|
76
|
-
if ("releasedAt" in data && String(data.releasedAt) !== String(originalDoc.releasedAt))
|
|
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
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
...
|
|
109
|
-
...
|
|
110
|
-
...
|
|
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
|
-
|
|
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
|
-
...
|
|
135
|
-
...
|
|
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
|
-
...
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
38
|
+
"@captiveorigins/onephylogeny-core": "^0.1.0-alpha.3"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"payload": "^3.80.0"
|