@captiveorigins/payload-plugin-onepedigree 0.0.0-stage → 0.1.0-alpha.11
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 +70 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Captive Origins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @captiveorigins/payload-plugin-onepedigree
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[OnePedigree](../../README.md) for Payload CMS 3: parentage on your own collection, cycle-safe
|
|
4
|
+
writes, deletion policies, permission-aware pedigree endpoints for all 13 views, and Admin
|
|
5
|
+
components (pedigree preview, relatives, color theme settings).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { onePedigree } from '@captiveorigins/payload-plugin-onepedigree'
|
|
9
|
+
|
|
10
|
+
export default buildConfig({
|
|
11
|
+
collections: [Animals, Clutches],
|
|
12
|
+
plugins: [
|
|
13
|
+
onePedigree({
|
|
14
|
+
collection: 'animals',
|
|
15
|
+
mode: 'standard-fields', // or 'managed' (creates the collections) or 'mapped' (your fields)
|
|
16
|
+
cohorts: { collection: 'clutches' },
|
|
17
|
+
hiddenRecords: 'placeholder', // a private relative shows as an anonymous "private record"
|
|
18
|
+
navigation: { individualUrl: '/animals/{id}' },
|
|
19
|
+
}),
|
|
20
|
+
],
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Modes
|
|
25
|
+
|
|
26
|
+
- **`managed`**: creates `individuals`-style and cohort collections from the factories
|
|
27
|
+
(`createIndividualsCollection`, `createCohortsCollection`).
|
|
28
|
+
- **`standard-fields`** (default): adds the `parentage` group (`parents[{ individual, role }]`,
|
|
29
|
+
`reproductiveMode`, `cohort`) to your existing collection.
|
|
30
|
+
- **`mapped`**: uses your fields, e.g. `{ kind: 'role-fields', roles: { mother: 'dam', father: 'sire' }, cohort: 'clutch' }`
|
|
31
|
+
or `{ kind: 'parent-array', path: 'lineage', individualField: 'animal', roleField: 'role' }`.
|
|
32
|
+
|
|
33
|
+
The plugin never creates collections for parents, roles, modes, graph edges or occurrences.
|
|
34
|
+
|
|
35
|
+
## Parent labels
|
|
36
|
+
|
|
37
|
+
Stored roles stay `mother` / `father`; only the words change. `terminology.parents` sets the
|
|
38
|
+
default (`'mother-father'` (default), `'dam-sire'`, `'female-male-parent'`, or custom
|
|
39
|
+
`{ maternal, paternal }`). Add `createParentTermsPreferenceField()` to your users collection
|
|
40
|
+
and pass its path as `terminology.userPreference`: each user then sees their own choice in the
|
|
41
|
+
Mother/Father pickers and in relatives.
|
|
42
|
+
|
|
43
|
+
## Cohorts are birthing events
|
|
44
|
+
|
|
45
|
+
A cohort is one birthing event (a clutch, a litter, twins): one mother, fathers may differ per
|
|
46
|
+
member. With `cohorts.birthParentField` (the cohort's field naming the mother), a member whose
|
|
47
|
+
recorded mother differs is refused, and so is changing the cohort's mother while members disagree.
|
|
48
|
+
|
|
49
|
+
## Integrity
|
|
50
|
+
|
|
51
|
+
On every write (Admin, REST, GraphQL, Local API, imports):
|
|
52
|
+
|
|
53
|
+
- self-parents, duplicated parents and **cycles** are rejected with field-addressable errors;
|
|
54
|
+
the cycle check walks the full stored descendant line;
|
|
55
|
+
- a parent the editor **cannot read** is rejected, with the same message as a missing one;
|
|
56
|
+
- host `rules` (e.g. taxonomy) run on the proposed record;
|
|
57
|
+
- parentage changes are **serialized**: each updates a graph-revision global in the same
|
|
58
|
+
transaction, so two concurrent edits that would together form a cycle cannot both commit
|
|
59
|
+
(requires a MongoDB replica set or another adapter with transactions). The revision also
|
|
60
|
+
serves as a cache key.
|
|
61
|
+
|
|
62
|
+
Deleting a recorded parent is refused (`onReferencedIndividualDelete: 'restrict'`, default) or
|
|
63
|
+
removes the references (`'detach'`).
|
|
64
|
+
|
|
65
|
+
## Endpoints
|
|
66
|
+
|
|
67
|
+
| Route | |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `GET /api/<collection>/:id/pedigree?view=…` | Projection + layout; `ancestorDepth`, `descendantDepth`, `relatedId`, `cohortId`, `occurrenceMode`, `includeSiblings`, `orientation`, `maxNodes`, `layout=false` |
|
|
70
|
+
| `GET /api/<collection>/:id/relatives` | Parents, offspring, siblings (full / partial / unresolved), cohort mates |
|
|
71
|
+
| `POST /api/<collection>/:id/pedigree/validate` | Check `{ parents, reproductiveMode, cohort }` without saving (needs update access) |
|
|
72
|
+
| `GET /api/<cohorts>/:id/members` | Members the requester may see |
|
|
73
|
+
|
|
74
|
+
Every read runs as the requester (access control applies), is bounded by `limits`
|
|
75
|
+
(`maxDepth`, `maxNodes`) and returns display projections, not documents. Records the requester
|
|
76
|
+
cannot read are omitted or replaced by anonymous placeholders; their IDs, names and links never
|
|
77
|
+
appear in responses. Add an index on the parent path (`parentage.parents.individual`, or your
|
|
78
|
+
role fields) in production; the standard fields declare it.
|
|
79
|
+
|
|
80
|
+
## Admin
|
|
81
|
+
|
|
82
|
+
- A pedigree panel on the edit view: preview of the saved record in any view (pan, zoom,
|
|
83
|
+
selection), relatives, and structural notices.
|
|
84
|
+
- A **Pedigree settings** global: color theme (seven presets, per-view accents, hex editing)
|
|
85
|
+
used by the preview; read it in your frontend to match.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ParentTerms } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
/** Mother/Father picker label in the signed-in user's own terms (e.g. Dam/Sire), else the site default. */
|
|
3
|
+
export declare function ParentRoleLabel({ side, defaultTerms, userPreference, path }: {
|
|
4
|
+
side: 'maternal' | 'paternal';
|
|
5
|
+
defaultTerms: ParentTerms;
|
|
6
|
+
userPreference: string | null;
|
|
7
|
+
path?: string;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { resolveParentTerms } from '@captiveorigins/onepedigree-core';
|
|
4
|
+
import { FieldLabel, useAuth } from '@payloadcms/ui';
|
|
5
|
+
const getAt = (doc, path) => path.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), doc);
|
|
6
|
+
/** Mother/Father picker label in the signed-in user's own terms (e.g. Dam/Sire), else the site default. */
|
|
7
|
+
export function ParentRoleLabel({ side, defaultTerms, userPreference, path }) {
|
|
8
|
+
const { user } = useAuth();
|
|
9
|
+
const terms = resolveParentTerms(userPreference && user ? getAt(user, userPreference) : undefined, defaultTerms);
|
|
10
|
+
return _jsx(FieldLabel, { label: terms[side], path: path });
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import '@captiveorigins/onepedigree-react/styles.css';
|
|
2
|
+
import type { PedigreeViewId } from '@captiveorigins/onepedigree-core';
|
|
3
|
+
export type PedigreePanelProps = {
|
|
4
|
+
views: PedigreeViewId[];
|
|
5
|
+
defaultView: PedigreeViewId;
|
|
6
|
+
settingsGlobal: string | null;
|
|
7
|
+
terminology?: {
|
|
8
|
+
individual?: string;
|
|
9
|
+
individuals?: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/** Admin: pedigree preview of the saved record (any view), derived relatives and structural notices. */
|
|
13
|
+
export declare function PedigreePanel(props: PedigreePanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import '@captiveorigins/onepedigree-react/styles.css';
|
|
4
|
+
import { PedigreeExplorer, VIEW_LABELS } from '@captiveorigins/onepedigree-react';
|
|
5
|
+
import { useConfig, useDocumentInfo } from '@payloadcms/ui';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
const name = (s) => ('private' in s ? 'Private record' : (s.label ?? s.id));
|
|
8
|
+
const list = (items) => (items.length ? items.map(name).join(', ') : '—');
|
|
9
|
+
/** Admin: pedigree preview of the saved record (any view), derived relatives and structural notices. */
|
|
10
|
+
export function PedigreePanel(props) {
|
|
11
|
+
const { id, collectionSlug } = useDocumentInfo();
|
|
12
|
+
const { config } = useConfig();
|
|
13
|
+
const [view, setView] = useState(props.defaultView);
|
|
14
|
+
const [data, setData] = useState(null);
|
|
15
|
+
const [relatives, setRelatives] = useState(null);
|
|
16
|
+
const [theme, setTheme] = useState(undefined);
|
|
17
|
+
const api = `${config.serverURL ?? ''}${config.routes.api}`;
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!id || !collectionSlug)
|
|
20
|
+
return;
|
|
21
|
+
let live = true;
|
|
22
|
+
fetch(`${api}/${collectionSlug}/${id}/pedigree?view=${view}`, { credentials: 'include' })
|
|
23
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
24
|
+
.then((j) => live && setData(j));
|
|
25
|
+
return () => {
|
|
26
|
+
live = false;
|
|
27
|
+
};
|
|
28
|
+
}, [api, collectionSlug, id, view]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!id || !collectionSlug)
|
|
31
|
+
return;
|
|
32
|
+
fetch(`${api}/${collectionSlug}/${id}/relatives`, { credentials: 'include' })
|
|
33
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
34
|
+
.then(setRelatives);
|
|
35
|
+
if (props.settingsGlobal)
|
|
36
|
+
fetch(`${api}/globals/${props.settingsGlobal}`, { credentials: 'include' })
|
|
37
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
38
|
+
.then((g) => g?.theme && setTheme(g.theme));
|
|
39
|
+
}, [api, collectionSlug, id, props.settingsGlobal]);
|
|
40
|
+
if (!id)
|
|
41
|
+
return _jsx("p", { className: "field-description", children: "Save this record to see its pedigree." });
|
|
42
|
+
return (_jsxs("div", { className: "onepedigree-panel", style: { marginBottom: 'var(--base, 20px)' }, children: [_jsx("label", { className: "field-label", htmlFor: "onepedigree-view", children: "Pedigree" }), _jsx("select", { id: "onepedigree-view", value: view, onChange: (e) => setView(e.target.value), children: props.views.map((v) => (_jsx("option", { value: v, children: VIEW_LABELS[v] }, v))) }), _jsx("p", { className: "field-description", children: "Shows the saved record; save to update." }), data ? _jsx(PedigreeExplorer, { projection: data.projection, layout: data.layout, height: "26rem", theme: theme, terminology: props.terminology }) : _jsx("p", { children: "Loading\u2026" }), data?.projection.issues.length ? (_jsx("ul", { className: "onepedigree-notices", children: data.projection.issues.map((i, n) => (_jsx("li", { children: i.message }, n))) })) : null, relatives && (_jsxs("dl", { className: "onepedigree-relatives", children: [_jsx("dt", { children: "Parents" }), _jsx("dd", { children: relatives.parents.length ? relatives.parents.map((p) => `${name(p.individual)} (${p.label})`).join(', ') : '—' }), _jsx("dt", { children: "Offspring" }), _jsx("dd", { children: list(relatives.children) }), _jsx("dt", { children: "Full siblings" }), _jsx("dd", { children: list(relatives.siblings.full) }), _jsx("dt", { children: "Partial siblings" }), _jsx("dd", { children: list(relatives.siblings.partial) }), relatives.siblings.unresolved.length > 0 && (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Same recorded parents, incomplete" }), _jsx("dd", { children: list(relatives.siblings.unresolved) })] })), relatives.cohort && (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Cohort mates" }), _jsx("dd", { children: list(relatives.cohort.mates) })] }))] }))] }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import '@captiveorigins/onepedigree-react/styles.css';
|
|
4
|
+
import { ThemeEditor, THEME_PRESETS, VIEW_LABELS } from '@captiveorigins/onepedigree-react';
|
|
5
|
+
import { useField } from '@payloadcms/ui';
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
/** Admin: the color theme of pedigree views (preset + hex adjustments), stored as JSON. */
|
|
8
|
+
export function ThemeField({ path }) {
|
|
9
|
+
const { value, setValue } = useField({ path });
|
|
10
|
+
const [view, setView] = useState('pedigree');
|
|
11
|
+
const theme = value && typeof value === 'object' && 'colors' in value ? value : structuredClone(THEME_PRESETS[0]);
|
|
12
|
+
return (_jsxs("div", { className: "field-type onepedigree-theme-field", style: { maxWidth: '24rem', marginBottom: 'var(--base, 20px)' }, children: [_jsx("label", { className: "field-label", htmlFor: "onepedigree-theme-view", children: "Preview colors for" }), _jsx("select", { id: "onepedigree-theme-view", value: view, onChange: (e) => setView(e.target.value), children: Object.keys(VIEW_LABELS).map((v) => (_jsx("option", { value: v, children: VIEW_LABELS[v] }, v))) }), _jsx(ThemeEditor, { value: theme, view: view, onChange: (t) => setValue(t) })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CollectionConfig, Field } from 'payload';
|
|
2
|
+
import { createParentageField } from './fields.js';
|
|
3
|
+
type FactoryOptions = {
|
|
4
|
+
slug: string;
|
|
5
|
+
labels?: {
|
|
6
|
+
singular: string;
|
|
7
|
+
plural: string;
|
|
8
|
+
};
|
|
9
|
+
additionalFields?: Field[];
|
|
10
|
+
access?: CollectionConfig['access'];
|
|
11
|
+
};
|
|
12
|
+
/** A minimal individuals collection: name, optional external ID and sex label, parentage. */
|
|
13
|
+
export declare const createIndividualsCollection: (o: FactoryOptions & Pick<Parameters<typeof createParentageField>[0], "cohorts" | "roles" | "selectors" | "terms" | "userPreference">) => CollectionConfig;
|
|
14
|
+
/** A minimal cohorts collection: a label plus host fields. Members are derived from each individual's parentage. */
|
|
15
|
+
export declare const createCohortsCollection: (o: FactoryOptions) => CollectionConfig;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createParentageField } from './fields.js';
|
|
2
|
+
const signedIn = ({ req }) => Boolean(req.user);
|
|
3
|
+
/** A minimal individuals collection: name, optional external ID and sex label, parentage. */
|
|
4
|
+
export const createIndividualsCollection = (o) => ({
|
|
5
|
+
slug: o.slug,
|
|
6
|
+
labels: o.labels ?? { singular: 'Individual', plural: 'Individuals' },
|
|
7
|
+
admin: { useAsTitle: 'name', defaultColumns: ['name', 'sex', 'updatedAt'] },
|
|
8
|
+
access: o.access ?? { read: () => true, create: signedIn, update: signedIn, delete: signedIn },
|
|
9
|
+
fields: [
|
|
10
|
+
{ name: 'name', type: 'text' },
|
|
11
|
+
{ type: 'row', fields: [{ name: 'externalId', type: 'text', index: true }, { name: 'sex', type: 'text' }] },
|
|
12
|
+
createParentageField({ individuals: o.slug, cohorts: o.cohorts, roles: o.roles, selectors: o.selectors, terms: o.terms, userPreference: o.userPreference }),
|
|
13
|
+
...(o.additionalFields ?? []),
|
|
14
|
+
],
|
|
15
|
+
timestamps: true,
|
|
16
|
+
});
|
|
17
|
+
/** A minimal cohorts collection: a label plus host fields. Members are derived from each individual's parentage. */
|
|
18
|
+
export const createCohortsCollection = (o) => ({
|
|
19
|
+
slug: o.slug,
|
|
20
|
+
labels: o.labels ?? { singular: 'Cohort', plural: 'Cohorts' },
|
|
21
|
+
admin: { useAsTitle: 'name' },
|
|
22
|
+
access: o.access ?? { read: () => true, create: signedIn, update: signedIn, delete: signedIn },
|
|
23
|
+
fields: [{ name: 'name', type: 'text' }, ...(o.additionalFields ?? [])],
|
|
24
|
+
timestamps: true,
|
|
25
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type Individual, type OccurrenceMode, type PedigreeIssue, type PedigreeRepository, type PedigreeViewId } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import { type Endpoint, type PayloadRequest } from 'payload';
|
|
3
|
+
import type { ResolvedOptions } from './types.js';
|
|
4
|
+
type Doc = Record<string, unknown>;
|
|
5
|
+
/** Reads as the requester (access control applies) unless `trusted`. */
|
|
6
|
+
export declare function payloadRepository(req: PayloadRequest, opts: ResolvedOptions, trusted?: boolean): PedigreeRepository<Doc>;
|
|
7
|
+
/**
|
|
8
|
+
* Removes every reference to a record the requester did not receive. With
|
|
9
|
+
* `placeholder`, requested-but-hidden parents become anonymous "private"
|
|
10
|
+
* individuals (fresh IDs per response, no name); otherwise they are omitted.
|
|
11
|
+
* Returns whether unloaded (not hidden) parents exist, i.e. the view is limited.
|
|
12
|
+
*/
|
|
13
|
+
export declare function redact(individuals: Individual[], hidden: ReadonlySet<string>, opts: ResolvedOptions): {
|
|
14
|
+
individuals: Individual[];
|
|
15
|
+
beyond: boolean;
|
|
16
|
+
hiddenSeen: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type PedigreeQuery = Partial<Record<'view' | 'ancestorDepth' | 'descendantDepth' | 'maxNodes' | 'relatedId' | 'cohortId' | 'occurrenceMode' | 'includeSiblings' | 'orientation' | 'layout', string>>;
|
|
19
|
+
/**
|
|
20
|
+
* A permission-filtered projection (and layout) of one view, read as the
|
|
21
|
+
* request's user. The endpoint uses it; host pages call it too (with a
|
|
22
|
+
* request from `createLocalReq`). Null when the focal record is not readable.
|
|
23
|
+
*/
|
|
24
|
+
export declare function getPedigree(req: PayloadRequest, opts: ResolvedOptions, id: string, query?: PedigreeQuery): Promise<{
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
revision: number;
|
|
27
|
+
projection: {
|
|
28
|
+
issues: PedigreeIssue[];
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
view: PedigreeViewId;
|
|
31
|
+
focalId: import("@captiveorigins/onepedigree-core").IndividualId;
|
|
32
|
+
occurrenceMode: OccurrenceMode;
|
|
33
|
+
occurrences: readonly import("@captiveorigins/onepedigree-core").IndividualOccurrence[];
|
|
34
|
+
edges: readonly import("@captiveorigins/onepedigree-core").ProjectedEdge[];
|
|
35
|
+
individuals: Readonly<Record<import("@captiveorigins/onepedigree-core").IndividualId, Individual>>;
|
|
36
|
+
structures: Readonly<Record<import("@captiveorigins/onepedigree-core").IndividualId, import("@captiveorigins/onepedigree-core").ReproductiveStructure>>;
|
|
37
|
+
groups: readonly import("@captiveorigins/onepedigree-core").ProjectionGroup[];
|
|
38
|
+
};
|
|
39
|
+
layout: import("@captiveorigins/onepedigree-core").LayoutResult | null;
|
|
40
|
+
} | null>;
|
|
41
|
+
/** GET /:id/pedigree?view=…: a permission-filtered projection (and layout) for one view. */
|
|
42
|
+
export declare const pedigreeEndpoint: (opts: ResolvedOptions) => Endpoint;
|
|
43
|
+
export type Summary = {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string | null;
|
|
46
|
+
sex: string | null;
|
|
47
|
+
href: string | null;
|
|
48
|
+
} | {
|
|
49
|
+
private: true;
|
|
50
|
+
};
|
|
51
|
+
/** Parents, offspring, siblings (full / partial / unresolved) and cohort mates the request's user may see. Null when not readable. */
|
|
52
|
+
export declare function getRelatives(req: PayloadRequest, opts: ResolvedOptions, id: string): Promise<{
|
|
53
|
+
parents: {
|
|
54
|
+
role: import("@captiveorigins/onepedigree-core").ParentRole | null;
|
|
55
|
+
label: string;
|
|
56
|
+
individual: Summary;
|
|
57
|
+
}[];
|
|
58
|
+
children: Summary[];
|
|
59
|
+
siblings: {
|
|
60
|
+
full: Summary[];
|
|
61
|
+
partial: Summary[];
|
|
62
|
+
unresolved: Summary[];
|
|
63
|
+
};
|
|
64
|
+
cohort: {
|
|
65
|
+
id: string;
|
|
66
|
+
mates: Summary[];
|
|
67
|
+
} | null;
|
|
68
|
+
} | null>;
|
|
69
|
+
/** GET /:id/relatives */
|
|
70
|
+
export declare const relativesEndpoint: (opts: ResolvedOptions) => Endpoint;
|
|
71
|
+
/** POST /:id/pedigree/validate { parents, reproductiveMode, cohort }: check a proposed change without saving. */
|
|
72
|
+
export declare const validateEndpoint: (opts: ResolvedOptions) => Endpoint;
|
|
73
|
+
/** GET /api/<cohorts>/:id/members: members the requester may see (from each member's parentage). */
|
|
74
|
+
export declare const cohortMembersEndpoint: (opts: ResolvedOptions) => Endpoint;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { getSiblings, indexPedigree, layoutSync, loadNeighborhood, PEDIGREE_VIEWS, projectPedigree, roleLabel, } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import { addDataAndFileToRequest, APIError } from 'payload';
|
|
3
|
+
import { checkParentage } from './integrity.js';
|
|
4
|
+
import { childrenWhere, documentAdapter, graphSelect, termsFor, toIndividual } from './mapping.js';
|
|
5
|
+
/** Reads as the requester (access control applies) unless `trusted`. */
|
|
6
|
+
export function payloadRepository(req, opts, trusted = false) {
|
|
7
|
+
const find = async (where) => (await req.payload.find({
|
|
8
|
+
collection: opts.collection,
|
|
9
|
+
where,
|
|
10
|
+
depth: 0,
|
|
11
|
+
limit: opts.limits.maxNodes,
|
|
12
|
+
pagination: false,
|
|
13
|
+
select: graphSelect(opts),
|
|
14
|
+
overrideAccess: trusted,
|
|
15
|
+
req,
|
|
16
|
+
})).docs;
|
|
17
|
+
return { getByIds: (ids) => find({ id: { in: [...ids] } }), findChildrenOf: (ids) => find(childrenWhere(opts, ids)) };
|
|
18
|
+
}
|
|
19
|
+
const int = (v, fallback, max) => {
|
|
20
|
+
const n = v === null ? fallback : Math.floor(Number(v));
|
|
21
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(max, n)) : fallback;
|
|
22
|
+
};
|
|
23
|
+
const hrefFor = (opts, id) => opts.navigation?.individualUrl?.replace('{id}', encodeURIComponent(id)) ?? null;
|
|
24
|
+
/**
|
|
25
|
+
* Removes every reference to a record the requester did not receive. With
|
|
26
|
+
* `placeholder`, requested-but-hidden parents become anonymous "private"
|
|
27
|
+
* individuals (fresh IDs per response, no name); otherwise they are omitted.
|
|
28
|
+
* Returns whether unloaded (not hidden) parents exist, i.e. the view is limited.
|
|
29
|
+
*/
|
|
30
|
+
export function redact(individuals, hidden, opts) {
|
|
31
|
+
const loaded = new Set(individuals.map((i) => i.id));
|
|
32
|
+
const placeholders = new Map();
|
|
33
|
+
let beyond = false;
|
|
34
|
+
let hiddenSeen = false;
|
|
35
|
+
const out = individuals.map((ind) => {
|
|
36
|
+
const parents = [];
|
|
37
|
+
for (const p of ind.parentage?.parents ?? []) {
|
|
38
|
+
if (loaded.has(p.individual))
|
|
39
|
+
parents.push(p);
|
|
40
|
+
else if (hidden.has(p.individual)) {
|
|
41
|
+
hiddenSeen = true;
|
|
42
|
+
if (opts.hiddenRecords === 'placeholder') {
|
|
43
|
+
if (!placeholders.has(p.individual))
|
|
44
|
+
placeholders.set(p.individual, `private-${placeholders.size + 1}`);
|
|
45
|
+
parents.push({ individual: placeholders.get(p.individual), role: p.role ?? null });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
beyond = true;
|
|
50
|
+
}
|
|
51
|
+
const data = { ...(ind.data ?? {}), href: hrefFor(opts, ind.id) };
|
|
52
|
+
return { ...ind, data, parentage: ind.parentage ? { ...ind.parentage, parents } : null };
|
|
53
|
+
});
|
|
54
|
+
for (const id of placeholders.values())
|
|
55
|
+
out.push({ id, label: null, sex: null, parentage: null, data: { private: true } });
|
|
56
|
+
return { individuals: out, beyond, hiddenSeen };
|
|
57
|
+
}
|
|
58
|
+
async function readable(req, collection, id) {
|
|
59
|
+
return req.payload.findByID({ collection: collection, id, depth: 0, overrideAccess: false, disableErrors: true, req });
|
|
60
|
+
}
|
|
61
|
+
async function canUpdate(req, opts, id) {
|
|
62
|
+
const access = req.payload.collections[opts.collection]?.config.access.update;
|
|
63
|
+
if (!access)
|
|
64
|
+
return Boolean(req.user);
|
|
65
|
+
const result = await access({ req, id, data: undefined });
|
|
66
|
+
if (result === true)
|
|
67
|
+
return true;
|
|
68
|
+
if (!result)
|
|
69
|
+
return false;
|
|
70
|
+
return (await req.payload.count({ collection: opts.collection, where: { and: [result, { id: { equals: id } }] }, overrideAccess: true, req })).totalDocs > 0;
|
|
71
|
+
}
|
|
72
|
+
const DOWN_VIEWS = new Set(['descendants', 'hourglass', 'population', 'timeline']);
|
|
73
|
+
/**
|
|
74
|
+
* A permission-filtered projection (and layout) of one view, read as the
|
|
75
|
+
* request's user. The endpoint uses it; host pages call it too (with a
|
|
76
|
+
* request from `createLocalReq`). Null when the focal record is not readable.
|
|
77
|
+
*/
|
|
78
|
+
export async function getPedigree(req, opts, id, query = {}) {
|
|
79
|
+
if (!(await readable(req, opts.collection, id)))
|
|
80
|
+
return null;
|
|
81
|
+
const q = { get: (k) => query[k] ?? null };
|
|
82
|
+
const enabled = opts.views?.enabled ?? PEDIGREE_VIEWS;
|
|
83
|
+
const view = (q.get('view') ?? opts.views?.default ?? 'pedigree');
|
|
84
|
+
if (!enabled.includes(view))
|
|
85
|
+
throw new APIError('Unknown or disabled view.', 400);
|
|
86
|
+
const max = opts.limits.maxDepth;
|
|
87
|
+
const ancestorDepth = int(q.get('ancestorDepth'), 4, max);
|
|
88
|
+
const descendantDepth = int(q.get('descendantDepth'), 3, max);
|
|
89
|
+
const maxNodes = int(q.get('maxNodes'), opts.limits.maxNodes, opts.limits.maxNodes);
|
|
90
|
+
const relatedId = q.get('relatedId') && (await readable(req, opts.collection, q.get('relatedId'))) ? q.get('relatedId') : undefined;
|
|
91
|
+
const modeParam = q.get('occurrenceMode');
|
|
92
|
+
const occurrenceMode = modeParam === 'merge' || modeParam === 'separate' || modeParam === 'hybrid' ? modeParam : undefined;
|
|
93
|
+
const focalIds = [id, ...(relatedId ? [relatedId] : [])];
|
|
94
|
+
let cohortId;
|
|
95
|
+
if (view === 'cohort' && opts.cohorts) {
|
|
96
|
+
cohortId = q.get('cohortId') ?? undefined;
|
|
97
|
+
const self = (await readable(req, opts.collection, id));
|
|
98
|
+
cohortId ??= String(toIndividual(self, opts).parentage?.cohort ?? '') || undefined;
|
|
99
|
+
if (cohortId) {
|
|
100
|
+
const { docs } = await req.payload.find({
|
|
101
|
+
collection: opts.collection,
|
|
102
|
+
where: { [opts.fields.parentage.cohort]: { equals: cohortId } },
|
|
103
|
+
depth: 0,
|
|
104
|
+
limit: maxNodes,
|
|
105
|
+
pagination: false,
|
|
106
|
+
select: {},
|
|
107
|
+
overrideAccess: false,
|
|
108
|
+
req,
|
|
109
|
+
});
|
|
110
|
+
focalIds.push(...docs.map((d) => String(d.id)));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const both = view === 'population' || view === 'timeline';
|
|
114
|
+
const n = await loadNeighborhood(payloadRepository(req, opts), documentAdapter(opts), {
|
|
115
|
+
focalIds,
|
|
116
|
+
ancestorDepth: view === 'cohort' ? 1 : view === 'descendants' ? 0 : relatedId ? max : ancestorDepth,
|
|
117
|
+
descendantDepth: DOWN_VIEWS.has(view) ? descendantDepth : 0,
|
|
118
|
+
includeSiblings: both || q.get('includeSiblings') === 'true',
|
|
119
|
+
maxNodes,
|
|
120
|
+
});
|
|
121
|
+
const { individuals, beyond, hiddenSeen } = redact([...n.document.individuals], new Set(n.unavailable), opts);
|
|
122
|
+
// Names of the cohorts involved that the requester may read (for cohort markers).
|
|
123
|
+
const cohortIds = [...new Set(individuals.map((i) => i.parentage?.cohort).filter((c) => Boolean(c)))];
|
|
124
|
+
const cohorts = opts.cohorts && cohortIds.length
|
|
125
|
+
? (await req.payload.find({
|
|
126
|
+
collection: opts.cohorts.collection,
|
|
127
|
+
where: { id: { in: cohortIds } },
|
|
128
|
+
depth: 0,
|
|
129
|
+
pagination: false,
|
|
130
|
+
overrideAccess: false,
|
|
131
|
+
req,
|
|
132
|
+
})).docs.map((c) => ({ id: String(c.id), label: typeof c[opts.cohorts.label] === 'string' ? c[opts.cohorts.label] : null }))
|
|
133
|
+
: [];
|
|
134
|
+
const document = { schemaVersion: 1, individuals, cohorts };
|
|
135
|
+
const projection = projectPedigree(document, {
|
|
136
|
+
focalId: id,
|
|
137
|
+
view,
|
|
138
|
+
ancestorDepth,
|
|
139
|
+
descendantDepth,
|
|
140
|
+
relatedId,
|
|
141
|
+
cohortId,
|
|
142
|
+
maxNodes,
|
|
143
|
+
includeSiblings: q.get('includeSiblings') === 'true',
|
|
144
|
+
includeCohortMembers: true,
|
|
145
|
+
lineageRoles: opts.lineageRoles,
|
|
146
|
+
...(occurrenceMode ? { occurrenceMode } : {}),
|
|
147
|
+
});
|
|
148
|
+
const issues = [...projection.issues];
|
|
149
|
+
if (hiddenSeen)
|
|
150
|
+
issues.push({ severity: 'notice', code: 'HIDDEN_RECORD', message: 'Some related records are private and not shown.' });
|
|
151
|
+
const result = { ...projection, issues, truncated: projection.truncated || n.truncated || beyond };
|
|
152
|
+
const revision = (await req.payload.findGlobal({ slug: opts.graphGlobal, depth: 0, overrideAccess: true, req }))?.revision ?? 0;
|
|
153
|
+
const orientation = q.get('orientation') === 'horizontal' ? 'horizontal' : 'vertical';
|
|
154
|
+
return {
|
|
155
|
+
schemaVersion: 1,
|
|
156
|
+
revision,
|
|
157
|
+
projection: result,
|
|
158
|
+
layout: q.get('layout') === 'false' ? null : layoutSync(result, { orientation }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** GET /:id/pedigree?view=…: a permission-filtered projection (and layout) for one view. */
|
|
162
|
+
export const pedigreeEndpoint = (opts) => ({
|
|
163
|
+
path: '/:id/pedigree',
|
|
164
|
+
method: 'get',
|
|
165
|
+
handler: async (req) => {
|
|
166
|
+
const params = new URL(req.url ?? 'http://local', 'http://local').searchParams;
|
|
167
|
+
const result = await getPedigree(req, opts, String(req.routeParams?.id), Object.fromEntries(params));
|
|
168
|
+
return result ? Response.json(result) : Response.json({ error: 'Not found' }, { status: 404 });
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const summary = (ind, opts) => ({ id: ind.id, label: ind.label ?? null, sex: ind.sex ?? null, href: hrefFor(opts, ind.id) });
|
|
172
|
+
/** Parents, offspring, siblings (full / partial / unresolved) and cohort mates the request's user may see. Null when not readable. */
|
|
173
|
+
export async function getRelatives(req, opts, id) {
|
|
174
|
+
if (!(await readable(req, opts.collection, id)))
|
|
175
|
+
return null;
|
|
176
|
+
const n = await loadNeighborhood(payloadRepository(req, opts), documentAdapter(opts), { focalIds: [id], ancestorDepth: 1, descendantDepth: 1, includeSiblings: true, maxNodes: opts.limits.maxNodes });
|
|
177
|
+
const { individuals } = redact([...n.document.individuals], new Set(n.unavailable), opts);
|
|
178
|
+
const index = indexPedigree({ schemaVersion: 1, individuals });
|
|
179
|
+
const at = (x) => (index.individuals.get(x)?.data?.private ? { private: true } : summary(index.individuals.get(x), opts));
|
|
180
|
+
const self = index.individuals.get(id);
|
|
181
|
+
const s = getSiblings(index, id);
|
|
182
|
+
const cohort = self.parentage?.cohort;
|
|
183
|
+
const cohortMates = cohort ? [...(index.membersOf.get(cohort) ?? [])].filter((m) => m !== id) : [];
|
|
184
|
+
const terms = await termsFor(opts, req.user);
|
|
185
|
+
return {
|
|
186
|
+
parents: (self.parentage?.parents ?? []).map((p) => ({ role: p.role ?? null, label: roleLabel(p.role, terms, opts.lineageRoles), individual: at(p.individual) })),
|
|
187
|
+
children: [...(index.childrenOf.get(id) ?? [])].map(at),
|
|
188
|
+
siblings: { full: s.full.map(at), partial: s.partial.map(at), unresolved: s.unresolved.map(at) },
|
|
189
|
+
cohort: cohort ? { id: cohort, mates: cohortMates.map(at) } : null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** GET /:id/relatives */
|
|
193
|
+
export const relativesEndpoint = (opts) => ({
|
|
194
|
+
path: '/:id/relatives',
|
|
195
|
+
method: 'get',
|
|
196
|
+
handler: async (req) => {
|
|
197
|
+
const result = await getRelatives(req, opts, String(req.routeParams?.id));
|
|
198
|
+
return result ? Response.json(result) : Response.json({ error: 'Not found' }, { status: 404 });
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
/** POST /:id/pedigree/validate { parents, reproductiveMode, cohort }: check a proposed change without saving. */
|
|
202
|
+
export const validateEndpoint = (opts) => ({
|
|
203
|
+
path: '/:id/pedigree/validate',
|
|
204
|
+
method: 'post',
|
|
205
|
+
handler: async (req) => {
|
|
206
|
+
const id = String(req.routeParams?.id);
|
|
207
|
+
if (!(await readable(req, opts.collection, id)) || !(await canUpdate(req, opts, id)))
|
|
208
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
209
|
+
await addDataAndFileToRequest(req);
|
|
210
|
+
const body = (req.data ?? {});
|
|
211
|
+
const parents = Array.isArray(body.parents)
|
|
212
|
+
? body.parents.flatMap((p) => p && typeof p === 'object' && typeof p.individual === 'string'
|
|
213
|
+
? [{ individual: p.individual, role: typeof p.role === 'string' ? p.role : null }]
|
|
214
|
+
: [])
|
|
215
|
+
: [];
|
|
216
|
+
const issues = await checkParentage(req, opts, {
|
|
217
|
+
id,
|
|
218
|
+
parents,
|
|
219
|
+
reproductiveMode: typeof body.reproductiveMode === 'string' ? body.reproductiveMode : null,
|
|
220
|
+
cohort: typeof body.cohort === 'string' ? body.cohort : null,
|
|
221
|
+
});
|
|
222
|
+
return Response.json({ valid: !issues.some((i) => i.severity === 'error'), issues });
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
/** GET /api/<cohorts>/:id/members: members the requester may see (from each member's parentage). */
|
|
226
|
+
export const cohortMembersEndpoint = (opts) => ({
|
|
227
|
+
path: '/:id/members',
|
|
228
|
+
method: 'get',
|
|
229
|
+
handler: async (req) => {
|
|
230
|
+
const id = String(req.routeParams?.id);
|
|
231
|
+
if (!opts.cohorts || !(await readable(req, opts.cohorts.collection, id)))
|
|
232
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
233
|
+
const { docs } = await req.payload.find({
|
|
234
|
+
collection: opts.collection,
|
|
235
|
+
where: { [opts.fields.parentage.cohort]: { equals: id } },
|
|
236
|
+
depth: 0,
|
|
237
|
+
limit: opts.limits.maxNodes,
|
|
238
|
+
pagination: false,
|
|
239
|
+
select: graphSelect(opts),
|
|
240
|
+
overrideAccess: false,
|
|
241
|
+
req,
|
|
242
|
+
});
|
|
243
|
+
return Response.json({ members: docs.map((d) => summary(toIndividual(d, opts), opts)) });
|
|
244
|
+
},
|
|
245
|
+
});
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ParentTerms, type ParentTermsPreset, type ReproductiveModeId } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import type { Field } from 'payload';
|
|
3
|
+
/** The canonical parents array: any number of contributors, each with an optional role. */
|
|
4
|
+
export declare const createParentsField: (o: {
|
|
5
|
+
individuals: string;
|
|
6
|
+
roles?: readonly string[];
|
|
7
|
+
}) => Field;
|
|
8
|
+
export declare const createReproductiveModeField: (o?: {
|
|
9
|
+
defaultValue?: ReproductiveModeId | null;
|
|
10
|
+
}) => Field;
|
|
11
|
+
export declare const createCohortField: (o: {
|
|
12
|
+
cohorts: string;
|
|
13
|
+
}) => Field;
|
|
14
|
+
/**
|
|
15
|
+
* A Mother/Father (or Dam/Sire) picker backed by the canonical parents array:
|
|
16
|
+
* shows the first parent with the role, and on save replaces the rows with
|
|
17
|
+
* that role when the picker changed. Not stored itself (virtual).
|
|
18
|
+
*/
|
|
19
|
+
export declare const createRoleSelectorField: (o: {
|
|
20
|
+
individuals: string;
|
|
21
|
+
side: "maternal" | "paternal";
|
|
22
|
+
terms?: ParentTermsPreset | ParentTerms;
|
|
23
|
+
userPreference?: string;
|
|
24
|
+
}) => Field;
|
|
25
|
+
/**
|
|
26
|
+
* A user's own words for the parent roles (add to your users collection and
|
|
27
|
+
* pass its path as `terminology.userPreference`). Display only.
|
|
28
|
+
*/
|
|
29
|
+
export declare const createParentTermsPreferenceField: (o?: {
|
|
30
|
+
name?: string;
|
|
31
|
+
}) => Field;
|
|
32
|
+
/** The standard `parentage` group (parents, reproductive mode, cohort), with Mother/Father pickers unless `selectors` is false. */
|
|
33
|
+
export declare const createParentageField: (o: {
|
|
34
|
+
individuals: string;
|
|
35
|
+
cohorts?: string | null;
|
|
36
|
+
roles?: readonly string[];
|
|
37
|
+
defaultMode?: ReproductiveModeId | null;
|
|
38
|
+
selectors?: boolean;
|
|
39
|
+
terms?: ParentTermsPreset | ParentTerms;
|
|
40
|
+
userPreference?: string;
|
|
41
|
+
}) => Field;
|