@captiveorigins/payload-plugin-onepedigree 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +88 -2
- package/dist/client/ParentRoleLabel.d.ts +8 -0
- package/dist/client/ParentRoleLabel.js +11 -0
- package/dist/client/PedigreePanel.d.ts +13 -0
- package/dist/client/PedigreePanel.js +43 -0
- package/dist/client/ThemeField.d.ts +5 -0
- package/dist/client/ThemeField.js +13 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +4 -0
- package/dist/collections.d.ts +16 -0
- package/dist/collections.js +25 -0
- package/dist/endpoints.d.ts +75 -0
- package/dist/endpoints.js +245 -0
- package/dist/fields.d.ts +41 -0
- package/dist/fields.js +135 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/integrity.d.ts +41 -0
- package/dist/integrity.js +211 -0
- package/dist/mapping.d.ts +22 -0
- package/dist/mapping.js +105 -0
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.js +135 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.js +1 -0
- package/package.json +66 -3
package/dist/fields.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { PARENT_TERM_PRESETS, REPRODUCTIVE_MODES, resolveParentTerms } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
const BUILT_IN_ROLES = ['mother', 'father', 'dam', 'sire', 'egg', 'sperm', 'donor', 'parent', 'contributor'];
|
|
3
|
+
const MODE_LABELS = {
|
|
4
|
+
sexual: 'Sexual',
|
|
5
|
+
parthenogenesis: 'Parthenogenesis',
|
|
6
|
+
self: 'Self',
|
|
7
|
+
asexual: 'Asexual',
|
|
8
|
+
cloning: 'Cloning',
|
|
9
|
+
other: 'Other',
|
|
10
|
+
};
|
|
11
|
+
const label = (s) => s[0].toUpperCase() + s.slice(1);
|
|
12
|
+
/** The canonical parents array: any number of contributors, each with an optional role. */
|
|
13
|
+
export const createParentsField = (o) => ({
|
|
14
|
+
name: 'parents',
|
|
15
|
+
label: 'All parents',
|
|
16
|
+
type: 'array',
|
|
17
|
+
admin: {
|
|
18
|
+
initCollapsed: true,
|
|
19
|
+
description: 'Every recorded parent (the pickers above edit the rows with their roles). Add other contributors or unknown-role parents here; roles are never inferred from sex.',
|
|
20
|
+
},
|
|
21
|
+
fields: [
|
|
22
|
+
{
|
|
23
|
+
type: 'row',
|
|
24
|
+
fields: [
|
|
25
|
+
// Indexed: descendant queries look parents up by this path.
|
|
26
|
+
{ name: 'individual', type: 'relationship', relationTo: o.individuals, required: true, index: true, admin: { width: '60%' } },
|
|
27
|
+
{
|
|
28
|
+
name: 'role',
|
|
29
|
+
type: 'select',
|
|
30
|
+
options: [...new Set([...BUILT_IN_ROLES, ...(o.roles ?? [])])].map((value) => ({ label: label(value), value })),
|
|
31
|
+
admin: { width: '40%' },
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
export const createReproductiveModeField = (o = {}) => ({
|
|
38
|
+
name: 'reproductiveMode',
|
|
39
|
+
type: 'select',
|
|
40
|
+
options: REPRODUCTIVE_MODES.map((value) => ({ label: MODE_LABELS[value], value })),
|
|
41
|
+
...(o.defaultValue ? { defaultValue: o.defaultValue } : {}),
|
|
42
|
+
admin: { description: 'How this individual originated. Empty means unknown. Changing it never adds or removes parents.' },
|
|
43
|
+
});
|
|
44
|
+
export const createCohortField = (o) => ({
|
|
45
|
+
name: 'cohort',
|
|
46
|
+
type: 'relationship',
|
|
47
|
+
relationTo: o.cohorts,
|
|
48
|
+
index: true,
|
|
49
|
+
admin: { description: 'The birthing event: a clutch, litter or other group born to the same mother at the same time. Fathers may differ.' },
|
|
50
|
+
});
|
|
51
|
+
const CLIENT = '@captiveorigins/payload-plugin-onepedigree/client';
|
|
52
|
+
const rowId = (v) => (v && typeof v === 'object' && 'id' in v ? String(v.id) : v ? String(v) : null);
|
|
53
|
+
/**
|
|
54
|
+
* A Mother/Father (or Dam/Sire) picker backed by the canonical parents array:
|
|
55
|
+
* shows the first parent with the role, and on save replaces the rows with
|
|
56
|
+
* that role when the picker changed. Not stored itself (virtual).
|
|
57
|
+
*/
|
|
58
|
+
export const createRoleSelectorField = (o) => {
|
|
59
|
+
const role = o.side === 'maternal' ? 'mother' : 'father';
|
|
60
|
+
const terms = resolveParentTerms(o.terms);
|
|
61
|
+
return {
|
|
62
|
+
name: role,
|
|
63
|
+
label: terms[o.side],
|
|
64
|
+
type: 'relationship',
|
|
65
|
+
relationTo: o.individuals,
|
|
66
|
+
virtual: true,
|
|
67
|
+
admin: {
|
|
68
|
+
width: '50%',
|
|
69
|
+
// Payload makes virtual fields read-only unless told otherwise.
|
|
70
|
+
readOnly: false,
|
|
71
|
+
// The label follows the signed-in user's preferred terms (e.g. Dam/Sire).
|
|
72
|
+
components: { Label: { path: `${CLIENT}#ParentRoleLabel`, clientProps: { side: o.side, defaultTerms: terms, userPreference: o.userPreference ?? null } } },
|
|
73
|
+
},
|
|
74
|
+
hooks: {
|
|
75
|
+
afterRead: [({ siblingData }) => rowId((siblingData?.parents ?? []).find((r) => r.role === role)?.individual)],
|
|
76
|
+
beforeChange: [
|
|
77
|
+
({ value, siblingData, previousSiblingDoc }) => {
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
const before = rowId((previousSiblingDoc?.parents ?? []).find((r) => r.role === role)?.individual);
|
|
81
|
+
const next = rowId(value);
|
|
82
|
+
if (next === before)
|
|
83
|
+
return undefined;
|
|
84
|
+
const rows = (siblingData.parents ?? []).filter((r) => r.role !== role);
|
|
85
|
+
siblingData.parents = next ? [{ individual: next, role }, ...rows] : rows;
|
|
86
|
+
return undefined;
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* A user's own words for the parent roles (add to your users collection and
|
|
94
|
+
* pass its path as `terminology.userPreference`). Display only.
|
|
95
|
+
*/
|
|
96
|
+
export const createParentTermsPreferenceField = (o = {}) => ({
|
|
97
|
+
name: o.name ?? 'parentTerms',
|
|
98
|
+
label: 'Parent labels',
|
|
99
|
+
type: 'group',
|
|
100
|
+
admin: { description: 'How pedigrees name the two parents for you. Stored data is the same either way.' },
|
|
101
|
+
fields: [
|
|
102
|
+
{
|
|
103
|
+
name: 'preset',
|
|
104
|
+
type: 'select',
|
|
105
|
+
options: [...Object.entries(PARENT_TERM_PRESETS).map(([value, p]) => ({ label: p.label, value })), { label: 'Custom', value: 'custom' }],
|
|
106
|
+
admin: { description: 'Empty: the site default.' },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: 'row',
|
|
110
|
+
admin: { condition: (_, sibling) => sibling?.preset === 'custom' },
|
|
111
|
+
fields: [
|
|
112
|
+
{ name: 'maternal', type: 'text', maxLength: 40, admin: { width: '50%', placeholder: 'Mother' } },
|
|
113
|
+
{ name: 'paternal', type: 'text', maxLength: 40, admin: { width: '50%', placeholder: 'Father' } },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
/** The standard `parentage` group (parents, reproductive mode, cohort), with Mother/Father pickers unless `selectors` is false. */
|
|
119
|
+
export const createParentageField = (o) => ({
|
|
120
|
+
name: 'parentage',
|
|
121
|
+
type: 'group',
|
|
122
|
+
fields: [
|
|
123
|
+
...(o.selectors === false
|
|
124
|
+
? []
|
|
125
|
+
: [
|
|
126
|
+
{
|
|
127
|
+
type: 'row',
|
|
128
|
+
fields: ['maternal', 'paternal'].map((side) => createRoleSelectorField({ individuals: o.individuals, side, terms: o.terms, userPreference: o.userPreference })),
|
|
129
|
+
},
|
|
130
|
+
]),
|
|
131
|
+
createParentsField({ individuals: o.individuals, roles: o.roles }),
|
|
132
|
+
createReproductiveModeField({ defaultValue: o.defaultMode }),
|
|
133
|
+
...(o.cohorts ? [createCohortField({ cohorts: o.cohorts })] : []),
|
|
134
|
+
],
|
|
135
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { onePedigree } from './plugin.js';
|
|
2
|
+
export { createCohortsCollection, createIndividualsCollection } from './collections.js';
|
|
3
|
+
export { createCohortField, createParentageField, createParentsField, createParentTermsPreferenceField, createReproductiveModeField, createRoleSelectorField } from './fields.js';
|
|
4
|
+
export { checkParentage, cohortBirthParentBeforeChange, parentageBeforeChange, parentBeforeDelete, SKIP } from './integrity.js';
|
|
5
|
+
export { getPedigree, getRelatives, payloadRepository, redact, type PedigreeQuery, type Summary } from './endpoints.js';
|
|
6
|
+
export { documentAdapter, readParents, resolveOptions, termsFor, toIndividual } from './mapping.js';
|
|
7
|
+
export type { OnePedigreePluginOptions, ParentageMapping, ResolvedOptions } from './types.js';
|
|
8
|
+
/** The resolved options of the installed plugin (for host pages calling getPedigree / getRelatives). */
|
|
9
|
+
export declare const pluginOptions: (config: {
|
|
10
|
+
custom?: Record<string, unknown>;
|
|
11
|
+
}) => import("./types.js").ResolvedOptions;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { onePedigree } from './plugin.js';
|
|
2
|
+
export { createCohortsCollection, createIndividualsCollection } from './collections.js';
|
|
3
|
+
export { createCohortField, createParentageField, createParentsField, createParentTermsPreferenceField, createReproductiveModeField, createRoleSelectorField } from './fields.js';
|
|
4
|
+
export { checkParentage, cohortBirthParentBeforeChange, parentageBeforeChange, parentBeforeDelete, SKIP } from './integrity.js';
|
|
5
|
+
export { getPedigree, getRelatives, payloadRepository, redact } from './endpoints.js';
|
|
6
|
+
export { documentAdapter, readParents, resolveOptions, termsFor, toIndividual } from './mapping.js';
|
|
7
|
+
/** The resolved options of the installed plugin (for host pages calling getPedigree / getRelatives). */
|
|
8
|
+
export const pluginOptions = (config) => config.custom?.onepedigree;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ParentReference, type PedigreeIssue, type ReproductiveModeId } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import { type CollectionBeforeChangeHook, type CollectionBeforeDeleteHook, type CollectionBeforeOperationHook, type PayloadRequest } from 'payload';
|
|
3
|
+
import type { ResolvedOptions } from './types.js';
|
|
4
|
+
/** Context flag for the plugin's own writes (detaching references, internal updates). */
|
|
5
|
+
export declare const SKIP = "onepedigreeInternal";
|
|
6
|
+
/** Set per operation: whether access control is bypassed (trusted server code), so parent readability is not checked. */
|
|
7
|
+
export declare const TRUSTED = "onepedigreeOverrideAccess";
|
|
8
|
+
/**
|
|
9
|
+
* Checks a proposed parentage for one individual against the stored graph:
|
|
10
|
+
* self-reference, duplicates, parents the requester cannot read (or that do
|
|
11
|
+
* not exist), cycles through the full descendant line (read with full access:
|
|
12
|
+
* integrity must not depend on what the editor can see), host rules, and
|
|
13
|
+
* structural notices.
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkParentage(req: PayloadRequest, opts: ResolvedOptions, proposal: {
|
|
16
|
+
id?: string | null;
|
|
17
|
+
parents: readonly ParentReference[];
|
|
18
|
+
reproductiveMode?: ReproductiveModeId | null;
|
|
19
|
+
cohort?: string | null;
|
|
20
|
+
doc?: Record<string, unknown>;
|
|
21
|
+
}, { trusted }?: {
|
|
22
|
+
trusted?: boolean;
|
|
23
|
+
}): Promise<PedigreeIssue[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Validates parentage on every write path (Admin, REST, GraphQL, Local API,
|
|
26
|
+
* imports) and serializes parentage changes: the graph revision global is
|
|
27
|
+
* updated in the same transaction, so two concurrent parentage writes cannot
|
|
28
|
+
* both commit (MongoDB write conflict) and jointly create a cycle.
|
|
29
|
+
*/
|
|
30
|
+
export declare const parentageBeforeChange: (opts: ResolvedOptions) => CollectionBeforeChangeHook;
|
|
31
|
+
/** Records whether this create/update bypasses access control (Local API default), for the readability check. */
|
|
32
|
+
export declare const rememberAccessMode: CollectionBeforeOperationHook;
|
|
33
|
+
/** Deleting a recorded parent: refuse (archive instead) or remove the references, per the plugin option. */
|
|
34
|
+
export declare const parentBeforeDelete: (opts: ResolvedOptions) => CollectionBeforeDeleteHook;
|
|
35
|
+
/** Structure of a document's recorded parentage (for Admin notices). */
|
|
36
|
+
export declare const structureOf: (doc: Record<string, unknown>, opts: ResolvedOptions) => import("@captiveorigins/onepedigree-core").ReproductiveStructure;
|
|
37
|
+
/**
|
|
38
|
+
* Cohorts: setting or changing the birthing parent is refused while members
|
|
39
|
+
* record a different mother (fix the members first, or move them to their own cohort).
|
|
40
|
+
*/
|
|
41
|
+
export declare const cohortBirthParentBeforeChange: (opts: ResolvedOptions) => CollectionBeforeChangeHook;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { reproductiveStructure, validatePedigree, } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import { APIError, ValidationError, } from 'payload';
|
|
3
|
+
import { childrenWhere, getAt, graphSelect, idOf, maternalRoles, readCohort, readMode, readParents, toIndividual } from './mapping.js';
|
|
4
|
+
/** Context flag for the plugin's own writes (detaching references, internal updates). */
|
|
5
|
+
export const SKIP = 'onepedigreeInternal';
|
|
6
|
+
/** Set per operation: whether access control is bypassed (trusted server code), so parent readability is not checked. */
|
|
7
|
+
export const TRUSTED = 'onepedigreeOverrideAccess';
|
|
8
|
+
const MAX_DESCENDANTS = 100_000;
|
|
9
|
+
const err = (code, message, path, relatedIds) => ({
|
|
10
|
+
severity: 'error',
|
|
11
|
+
code,
|
|
12
|
+
message,
|
|
13
|
+
...(path ? { path } : {}),
|
|
14
|
+
...(relatedIds ? { relatedIds } : {}),
|
|
15
|
+
});
|
|
16
|
+
const parentsPath = (opts) => (opts.fields.parentage.kind === 'parent-array' ? `${opts.fields.parentage.path}.parents` : Object.values(opts.fields.parentage.roles)[0]);
|
|
17
|
+
/**
|
|
18
|
+
* Checks a proposed parentage for one individual against the stored graph:
|
|
19
|
+
* self-reference, duplicates, parents the requester cannot read (or that do
|
|
20
|
+
* not exist), cycles through the full descendant line (read with full access:
|
|
21
|
+
* integrity must not depend on what the editor can see), host rules, and
|
|
22
|
+
* structural notices.
|
|
23
|
+
*/
|
|
24
|
+
export async function checkParentage(req, opts, proposal, { trusted = false } = {}) {
|
|
25
|
+
const issues = [];
|
|
26
|
+
const path = parentsPath(opts);
|
|
27
|
+
const ids = proposal.parents.map((p) => p.individual);
|
|
28
|
+
if (proposal.id && ids.includes(proposal.id))
|
|
29
|
+
issues.push(err('SELF_PARENT', 'An individual cannot be its own parent.', path));
|
|
30
|
+
if (new Set(ids).size !== ids.length)
|
|
31
|
+
issues.push(err('DUPLICATE_PARENT', 'The same parent is listed more than once.', path));
|
|
32
|
+
const unique = [...new Set(ids)];
|
|
33
|
+
const visible = unique.length
|
|
34
|
+
? (await req.payload.find({
|
|
35
|
+
collection: opts.collection,
|
|
36
|
+
where: { id: { in: unique } },
|
|
37
|
+
depth: 0,
|
|
38
|
+
pagination: false,
|
|
39
|
+
select: graphSelect(opts),
|
|
40
|
+
overrideAccess: trusted,
|
|
41
|
+
req,
|
|
42
|
+
})).docs
|
|
43
|
+
: [];
|
|
44
|
+
const seen = new Set(visible.map((d) => String(d.id)));
|
|
45
|
+
// Same message whether missing or private: nothing is revealed about records the editor cannot read.
|
|
46
|
+
for (const p of unique)
|
|
47
|
+
if (!seen.has(p))
|
|
48
|
+
issues.push(err('UNRESOLVED_PARENT', 'A parent does not exist or you cannot access it.', path, [p]));
|
|
49
|
+
if (proposal.id && unique.length) {
|
|
50
|
+
const proposed = new Set(unique);
|
|
51
|
+
const reached = new Set([proposal.id]);
|
|
52
|
+
let frontier = [proposal.id];
|
|
53
|
+
while (frontier.length && !issues.some((i) => i.code === 'PARENT_IS_DESCENDANT')) {
|
|
54
|
+
const { docs } = await req.payload.find({
|
|
55
|
+
collection: opts.collection,
|
|
56
|
+
where: childrenWhere(opts, frontier),
|
|
57
|
+
depth: 0,
|
|
58
|
+
pagination: false,
|
|
59
|
+
select: {},
|
|
60
|
+
overrideAccess: true,
|
|
61
|
+
req,
|
|
62
|
+
});
|
|
63
|
+
frontier = [];
|
|
64
|
+
for (const d of docs) {
|
|
65
|
+
const c = String(d.id);
|
|
66
|
+
if (reached.has(c))
|
|
67
|
+
continue;
|
|
68
|
+
reached.add(c);
|
|
69
|
+
if (proposed.has(c))
|
|
70
|
+
issues.push(err('PARENT_IS_DESCENDANT', 'This parent already descends from the individual; the pedigree would loop.', path, [c]));
|
|
71
|
+
frontier.push(c);
|
|
72
|
+
}
|
|
73
|
+
if (reached.size > MAX_DESCENDANTS) {
|
|
74
|
+
issues.push(err('TRAVERSAL_LIMIT', 'Too many descendants to verify this change safely.', path));
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// A cohort is one birthing event: the member's recorded mother must be the cohort's birthing parent.
|
|
80
|
+
if (proposal.cohort && opts.cohorts?.birthParentField) {
|
|
81
|
+
const cohort = await req.payload.findByID({
|
|
82
|
+
collection: opts.cohorts.collection,
|
|
83
|
+
id: proposal.cohort,
|
|
84
|
+
depth: 0,
|
|
85
|
+
overrideAccess: true,
|
|
86
|
+
disableErrors: true,
|
|
87
|
+
req,
|
|
88
|
+
});
|
|
89
|
+
const birthParent = idOf(cohort ? getAt(cohort, opts.cohorts.birthParentField) : null);
|
|
90
|
+
const mothers = proposal.parents.filter((p) => p.role && maternalRoles(opts).includes(p.role)).map((p) => p.individual);
|
|
91
|
+
if (birthParent && mothers.some((m) => m !== birthParent))
|
|
92
|
+
issues.push(err('COHORT_BIRTH_PARENT_MISMATCH', 'This mother differs from the mother of the selected birthing event (cohort).', opts.fields.parentage.cohort ?? path));
|
|
93
|
+
}
|
|
94
|
+
// Host rules and structural notices on the proposed record with its visible parents.
|
|
95
|
+
const self = {
|
|
96
|
+
...(proposal.doc ? toIndividual({ ...proposal.doc, id: proposal.id ?? '(new)' }, opts) : { id: proposal.id ?? '(new)' }),
|
|
97
|
+
parentage: { parents: proposal.parents, reproductiveMode: proposal.reproductiveMode ?? null, cohort: proposal.cohort ?? null },
|
|
98
|
+
};
|
|
99
|
+
// The record itself is never one of its own parents here (reported above as SELF_PARENT).
|
|
100
|
+
const others = visible.filter((d) => String(d.id) !== self.id).map((d) => toIndividual(d, opts));
|
|
101
|
+
const document = { schemaVersion: 1, individuals: [self, ...others] };
|
|
102
|
+
const result = validatePedigree(document, { complete: false, rules: opts.rules, birthParentRoles: maternalRoles(opts) });
|
|
103
|
+
for (const i of result.issues)
|
|
104
|
+
if (i.individualId === self.id && !['SELF_PARENT', 'DUPLICATE_PARENT', 'ANCESTRY_CYCLE'].includes(i.code))
|
|
105
|
+
issues.push({ ...i, path: i.path ?? path });
|
|
106
|
+
// Rules may report on other records too; only errors about this write block it.
|
|
107
|
+
return issues;
|
|
108
|
+
}
|
|
109
|
+
const same = (a, b) => a.length === b.length && a.every((p, i) => p.individual === b[i].individual && (p.role ?? null) === (b[i].role ?? null));
|
|
110
|
+
/**
|
|
111
|
+
* Validates parentage on every write path (Admin, REST, GraphQL, Local API,
|
|
112
|
+
* imports) and serializes parentage changes: the graph revision global is
|
|
113
|
+
* updated in the same transaction, so two concurrent parentage writes cannot
|
|
114
|
+
* both commit (MongoDB write conflict) and jointly create a cycle.
|
|
115
|
+
*/
|
|
116
|
+
export const parentageBeforeChange = (opts) => async ({ data, originalDoc, req, context, operation }) => {
|
|
117
|
+
if (context[SKIP])
|
|
118
|
+
return data;
|
|
119
|
+
const merged = { ...(originalDoc ?? {}), ...data };
|
|
120
|
+
const id = operation === 'update' ? (idOf(originalDoc?.id) ?? null) : null;
|
|
121
|
+
const parents = readParents(merged, opts);
|
|
122
|
+
const mode = readMode(merged, opts);
|
|
123
|
+
const cohort = readCohort(merged, opts);
|
|
124
|
+
const before = originalDoc ? readParents(originalDoc, opts) : [];
|
|
125
|
+
if (operation === 'update' && same(parents, before) && mode === readMode(originalDoc, opts) && cohort === readCohort(originalDoc, opts))
|
|
126
|
+
return data;
|
|
127
|
+
const issues = await checkParentage(req, opts, { id, parents, reproductiveMode: mode, cohort, doc: merged }, { trusted: context[TRUSTED] === true });
|
|
128
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
129
|
+
if (errors.length)
|
|
130
|
+
throw new ValidationError({
|
|
131
|
+
collection: opts.collection,
|
|
132
|
+
errors: errors.map((e) => ({ path: e.path ?? 'parentage', message: e.message })),
|
|
133
|
+
});
|
|
134
|
+
if (!same(parents, before)) {
|
|
135
|
+
const current = (await req.payload.findGlobal({ slug: opts.graphGlobal, depth: 0, overrideAccess: true, req }));
|
|
136
|
+
await req.payload.updateGlobal({
|
|
137
|
+
slug: opts.graphGlobal,
|
|
138
|
+
data: { revision: (current?.revision ?? 0) + 1 },
|
|
139
|
+
overrideAccess: true,
|
|
140
|
+
context: { [SKIP]: true },
|
|
141
|
+
req,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return data;
|
|
145
|
+
};
|
|
146
|
+
/** Records whether this create/update bypasses access control (Local API default), for the readability check. */
|
|
147
|
+
export const rememberAccessMode = ({ args, operation, context }) => {
|
|
148
|
+
if (operation === 'create' || operation === 'update' || operation === 'updateByID')
|
|
149
|
+
context[TRUSTED] = args.overrideAccess !== false;
|
|
150
|
+
return args;
|
|
151
|
+
};
|
|
152
|
+
/** Deleting a recorded parent: refuse (archive instead) or remove the references, per the plugin option. */
|
|
153
|
+
export const parentBeforeDelete = (opts) => async ({ id, req, context }) => {
|
|
154
|
+
if (context[SKIP])
|
|
155
|
+
return;
|
|
156
|
+
const { docs } = await req.payload.find({
|
|
157
|
+
collection: opts.collection,
|
|
158
|
+
where: childrenWhere(opts, [String(id)]),
|
|
159
|
+
depth: 0,
|
|
160
|
+
pagination: false,
|
|
161
|
+
limit: opts.onReferencedIndividualDelete === 'restrict' ? 1 : 0,
|
|
162
|
+
overrideAccess: true,
|
|
163
|
+
req,
|
|
164
|
+
});
|
|
165
|
+
if (!docs.length)
|
|
166
|
+
return;
|
|
167
|
+
if (opts.onReferencedIndividualDelete === 'restrict')
|
|
168
|
+
throw new APIError('This individual is a recorded parent. Archive it instead, or remove it from its offspring first.', 409);
|
|
169
|
+
const m = opts.fields.parentage;
|
|
170
|
+
for (const child of docs) {
|
|
171
|
+
const data = m.kind === 'role-fields'
|
|
172
|
+
? Object.fromEntries(Object.values(m.roles).filter((p) => idOf(getAt(child, p)) === String(id)).map((p) => [p, null]))
|
|
173
|
+
: {
|
|
174
|
+
[m.path]: {
|
|
175
|
+
...(getAt(child, m.path) ?? {}),
|
|
176
|
+
parents: (getAt(child, `${m.path}.parents`) ?? []).filter((r) => idOf(r[m.individualField]) !== String(id)),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
await req.payload.update({ collection: opts.collection, id: child.id, data: data, overrideAccess: true, context: { [SKIP]: true }, req });
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
/** Structure of a document's recorded parentage (for Admin notices). */
|
|
183
|
+
export const structureOf = (doc, opts) => reproductiveStructure(toIndividual(doc, opts));
|
|
184
|
+
/**
|
|
185
|
+
* Cohorts: setting or changing the birthing parent is refused while members
|
|
186
|
+
* record a different mother (fix the members first, or move them to their own cohort).
|
|
187
|
+
*/
|
|
188
|
+
export const cohortBirthParentBeforeChange = (opts) => async ({ data, originalDoc, req, operation, context }) => {
|
|
189
|
+
const field = opts.cohorts?.birthParentField;
|
|
190
|
+
if (!field || operation !== 'update' || context[SKIP])
|
|
191
|
+
return data;
|
|
192
|
+
const next = idOf(getAt({ ...originalDoc, ...data }, field));
|
|
193
|
+
if (!next || next === idOf(getAt(originalDoc, field)))
|
|
194
|
+
return data;
|
|
195
|
+
const { docs } = await req.payload.find({
|
|
196
|
+
collection: opts.collection,
|
|
197
|
+
where: { [opts.fields.parentage.cohort]: { equals: originalDoc.id } },
|
|
198
|
+
depth: 0,
|
|
199
|
+
pagination: false,
|
|
200
|
+
select: graphSelect(opts),
|
|
201
|
+
overrideAccess: true,
|
|
202
|
+
req,
|
|
203
|
+
});
|
|
204
|
+
const conflicting = docs.filter((d) => readParents(d, opts).some((p) => p.role && maternalRoles(opts).includes(p.role) && p.individual !== next)).length;
|
|
205
|
+
if (conflicting)
|
|
206
|
+
throw new ValidationError({
|
|
207
|
+
collection: opts.cohorts.collection,
|
|
208
|
+
errors: [{ path: field, message: `${conflicting} member(s) of this birthing event record a different mother.` }],
|
|
209
|
+
});
|
|
210
|
+
return data;
|
|
211
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Individual, type ParentReference, type PedigreeAdapter, type ReproductiveModeId } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import type { Where } from 'payload';
|
|
3
|
+
import type { OnePedigreePluginOptions, ResolvedOptions } from './types.js';
|
|
4
|
+
export declare const idOf: (v: unknown) => string | null;
|
|
5
|
+
export declare const getAt: (doc: unknown, path: string) => unknown;
|
|
6
|
+
export declare function resolveOptions(o: OnePedigreePluginOptions): ResolvedOptions;
|
|
7
|
+
/** The stored parent references of a document (relationship values may be IDs or populated docs). */
|
|
8
|
+
export declare function readParents(doc: unknown, opts: ResolvedOptions): ParentReference[];
|
|
9
|
+
export declare const readMode: (doc: unknown, opts: ResolvedOptions) => ReproductiveModeId | null;
|
|
10
|
+
export declare const readCohort: (doc: unknown, opts: ResolvedOptions) => string | null;
|
|
11
|
+
/** Paths that hold parent IDs, for queries and indexes. */
|
|
12
|
+
export declare function parentPaths(opts: ResolvedOptions): string[];
|
|
13
|
+
/** Records that list any of `ids` as a parent. */
|
|
14
|
+
export declare const childrenWhere: (opts: ResolvedOptions, ids: readonly string[]) => Where;
|
|
15
|
+
/** Fields to read for the graph (keeps queries small; never the whole document). */
|
|
16
|
+
export declare function graphSelect(opts: ResolvedOptions): Record<string, true>;
|
|
17
|
+
export declare function documentAdapter(opts: ResolvedOptions): PedigreeAdapter<Record<string, unknown>>;
|
|
18
|
+
export declare const toIndividual: (doc: Record<string, unknown>, opts: ResolvedOptions) => Individual;
|
|
19
|
+
/** Maternal (birthing) roles. */
|
|
20
|
+
export declare const maternalRoles: (opts: ResolvedOptions) => readonly string[];
|
|
21
|
+
/** Parent terms for a request: the user's preference (if configured), else the installation default. */
|
|
22
|
+
export declare function termsFor(opts: ResolvedOptions, user: unknown): Promise<import("@captiveorigins/onepedigree-core").ParentTerms>;
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { adaptIndividual, REPRODUCTIVE_MODES } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
export const idOf = (v) => v && typeof v === 'object' && 'id' in v ? String(v.id) : v !== undefined && v !== null && v !== '' ? String(v) : null;
|
|
3
|
+
export const getAt = (doc, path) => path.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), doc);
|
|
4
|
+
export function resolveOptions(o) {
|
|
5
|
+
const mapping = o.fields?.parentage ?? { kind: 'parent-array' };
|
|
6
|
+
const parentage = mapping.kind === 'role-fields'
|
|
7
|
+
? mapping
|
|
8
|
+
: {
|
|
9
|
+
kind: 'parent-array',
|
|
10
|
+
path: mapping.path ?? 'parentage',
|
|
11
|
+
individualField: mapping.individualField ?? 'individual',
|
|
12
|
+
roleField: mapping.roleField ?? 'role',
|
|
13
|
+
reproductiveMode: mapping.reproductiveMode ?? `${mapping.path ?? 'parentage'}.reproductiveMode`,
|
|
14
|
+
cohort: mapping.cohort ?? `${mapping.path ?? 'parentage'}.cohort`,
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
...o,
|
|
18
|
+
mode: o.mode ?? 'standard-fields',
|
|
19
|
+
hiddenRecords: o.hiddenRecords ?? 'omit',
|
|
20
|
+
onReferencedIndividualDelete: o.onReferencedIndividualDelete ?? 'restrict',
|
|
21
|
+
fields: { label: o.fields?.label ?? 'name', sex: o.fields?.sex ?? 'sex', parentage, display: o.fields?.display ?? [] },
|
|
22
|
+
cohorts: o.cohorts
|
|
23
|
+
? { collection: o.cohorts.collection, label: o.cohorts.label ?? 'name', managed: o.cohorts.managed ?? false, birthParentField: o.cohorts.birthParentField ?? null }
|
|
24
|
+
: null,
|
|
25
|
+
limits: { maxDepth: o.limits?.maxDepth ?? 10, maxNodes: o.limits?.maxNodes ?? 500 },
|
|
26
|
+
graphGlobal: `${o.collection}-pedigree-graph`,
|
|
27
|
+
settingsGlobal: 'pedigree-settings',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** The stored parent references of a document (relationship values may be IDs or populated docs). */
|
|
31
|
+
export function readParents(doc, opts) {
|
|
32
|
+
const m = opts.fields.parentage;
|
|
33
|
+
if (m.kind === 'role-fields')
|
|
34
|
+
return Object.entries(m.roles).flatMap(([role, path]) => {
|
|
35
|
+
const id = idOf(getAt(doc, path));
|
|
36
|
+
return id ? [{ individual: id, role }] : [];
|
|
37
|
+
});
|
|
38
|
+
const rows = getAt(doc, `${m.path}.parents`);
|
|
39
|
+
if (!Array.isArray(rows))
|
|
40
|
+
return [];
|
|
41
|
+
return rows.flatMap((r) => {
|
|
42
|
+
const id = idOf(r?.[m.individualField]);
|
|
43
|
+
const role = r?.[m.roleField];
|
|
44
|
+
return id ? [{ individual: id, role: typeof role === 'string' && role ? role : null }] : [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export const readMode = (doc, opts) => {
|
|
48
|
+
const path = opts.fields.parentage.reproductiveMode;
|
|
49
|
+
const v = path ? getAt(doc, path) : null;
|
|
50
|
+
return typeof v === 'string' && REPRODUCTIVE_MODES.includes(v) ? v : null;
|
|
51
|
+
};
|
|
52
|
+
export const readCohort = (doc, opts) => {
|
|
53
|
+
const path = opts.fields.parentage.cohort;
|
|
54
|
+
return path && opts.cohorts ? idOf(getAt(doc, path)) : null;
|
|
55
|
+
};
|
|
56
|
+
/** Paths that hold parent IDs, for queries and indexes. */
|
|
57
|
+
export function parentPaths(opts) {
|
|
58
|
+
const m = opts.fields.parentage;
|
|
59
|
+
return m.kind === 'role-fields' ? Object.values(m.roles) : [`${m.path}.parents.${m.individualField}`];
|
|
60
|
+
}
|
|
61
|
+
/** Records that list any of `ids` as a parent. */
|
|
62
|
+
export const childrenWhere = (opts, ids) => ({
|
|
63
|
+
or: parentPaths(opts).map((p) => ({ [p]: { in: [...ids] } })),
|
|
64
|
+
});
|
|
65
|
+
/** Fields to read for the graph (keeps queries small; never the whole document). */
|
|
66
|
+
export function graphSelect(opts) {
|
|
67
|
+
const m = opts.fields.parentage;
|
|
68
|
+
const top = (p) => p.split('.')[0];
|
|
69
|
+
const keys = new Set([opts.fields.label, ...(opts.fields.sex ? [opts.fields.sex] : []), ...opts.fields.display.map(top)]);
|
|
70
|
+
if (m.kind === 'role-fields')
|
|
71
|
+
for (const p of Object.values(m.roles))
|
|
72
|
+
keys.add(top(p));
|
|
73
|
+
else
|
|
74
|
+
keys.add(top(m.path));
|
|
75
|
+
for (const p of [m.reproductiveMode, m.cohort])
|
|
76
|
+
if (p)
|
|
77
|
+
keys.add(top(p));
|
|
78
|
+
return Object.fromEntries([...keys].map((k) => [k, true]));
|
|
79
|
+
}
|
|
80
|
+
export function documentAdapter(opts) {
|
|
81
|
+
return {
|
|
82
|
+
getId: (d) => String(d.id),
|
|
83
|
+
getParents: (d) => readParents(d, opts),
|
|
84
|
+
getReproductiveMode: (d) => readMode(d, opts),
|
|
85
|
+
getCohortId: (d) => readCohort(d, opts),
|
|
86
|
+
getLabel: (d) => {
|
|
87
|
+
const v = getAt(d, opts.fields.label);
|
|
88
|
+
return typeof v === 'string' && v ? v : null;
|
|
89
|
+
},
|
|
90
|
+
getSexLabel: (d) => {
|
|
91
|
+
const v = opts.fields.sex ? getAt(d, opts.fields.sex) : null;
|
|
92
|
+
return typeof v === 'string' && v ? v : null;
|
|
93
|
+
},
|
|
94
|
+
getDisplayData: (d) => Object.fromEntries(opts.fields.display.map((p) => [p, getAt(d, p) ?? null])),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export const toIndividual = (doc, opts) => adaptIndividual(doc, documentAdapter(opts));
|
|
98
|
+
/** Maternal (birthing) roles. */
|
|
99
|
+
export const maternalRoles = (opts) => opts.lineageRoles?.maternal ?? ['mother', 'dam', 'egg'];
|
|
100
|
+
/** Parent terms for a request: the user's preference (if configured), else the installation default. */
|
|
101
|
+
export async function termsFor(opts, user) {
|
|
102
|
+
const { resolveParentTerms } = await import('@captiveorigins/onepedigree-core');
|
|
103
|
+
const pref = opts.terminology?.userPreference && user ? getAt(user, opts.terminology.userPreference) : undefined;
|
|
104
|
+
return resolveParentTerms(pref, opts.terminology?.parents ?? 'mother-father');
|
|
105
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Plugin } from 'payload';
|
|
2
|
+
import type { OnePedigreePluginOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* OnePedigree for Payload. Maps (or creates) an individuals collection,
|
|
5
|
+
* validates parentage on every write, serializes parentage changes, applies
|
|
6
|
+
* the deletion policy, and adds permission-aware pedigree endpoints and Admin
|
|
7
|
+
* components. Never creates collections for parents, roles, modes, edges or
|
|
8
|
+
* occurrences.
|
|
9
|
+
*/
|
|
10
|
+
export declare const onePedigree: (options: OnePedigreePluginOptions) => Plugin;
|