@medicine-wheel/app 0.4.12 → 0.5.0
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/app/accountability/layout.tsx +7 -0
- package/app/accountability/page.tsx +7 -7
- package/app/api/edges/route.ts +198 -15
- package/app/api/nodes/[id]/route.ts +101 -0
- package/app/api/nodes/route.ts +39 -12
- package/app/api/plan-perspectives/[id]/route.ts +28 -0
- package/app/api/plan-perspectives/route.ts +138 -0
- package/app/ceremonies/layout.tsx +7 -0
- package/app/ceremonies/page.tsx +17 -14
- package/app/globals.css +78 -36
- package/app/graph/medicine-wheel-flow.css +157 -0
- package/app/graph/page.tsx +152 -1
- package/app/layout.tsx +32 -3
- package/app/narrative/beats/layout.tsx +7 -0
- package/app/narrative/beats/page.tsx +17 -14
- package/app/narrative/cycles/layout.tsx +7 -0
- package/app/narrative/cycles/page.tsx +13 -10
- package/app/narrative/layout.tsx +9 -0
- package/app/narrative/page.tsx +11 -8
- package/app/nodes/page.tsx +256 -82
- package/app/page.tsx +144 -61
- package/app/relations/page.tsx +338 -157
- package/components/direction-panel.tsx +21 -9
- package/components/navigation.tsx +24 -31
- package/components/workspaces-panel.tsx +10 -10
- package/package.json +21 -21
- package/public/favicon.svg +14 -0
- package/public/fonts/Stereohead.otf +0 -0
|
@@ -40,7 +40,7 @@ export default function AccountabilityPage() {
|
|
|
40
40
|
|
|
41
41
|
return (
|
|
42
42
|
<div className="p-6 max-w-5xl mx-auto">
|
|
43
|
-
<h1 className="
|
|
43
|
+
<h1 className="mw-h1 mb-2">Accountability & frameworks</h1>
|
|
44
44
|
<p className="text-sm text-muted-foreground mb-6">Relational accountability metrics and Indigenous research frameworks</p>
|
|
45
45
|
|
|
46
46
|
{loading && <div className="text-center py-16 text-muted-foreground">Loading accountability metrics…</div>}
|
|
@@ -49,26 +49,26 @@ export default function AccountabilityPage() {
|
|
|
49
49
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
50
50
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
51
51
|
<p className="text-3xl font-bold">{nodes.length}</p>
|
|
52
|
-
<p className="text-sm text-muted-foreground">Relations
|
|
52
|
+
<p className="text-sm text-muted-foreground">Relations mapped</p>
|
|
53
53
|
<p className="text-xs text-muted-foreground">{nodes.length} nodes · {edges.length} edges</p>
|
|
54
54
|
</div>
|
|
55
55
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
56
56
|
<p className="text-3xl font-bold">{ceremonies.length}</p>
|
|
57
|
-
<p className="text-sm text-muted-foreground">Ceremonies
|
|
57
|
+
<p className="text-sm text-muted-foreground">Ceremonies logged</p>
|
|
58
58
|
</div>
|
|
59
59
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
60
60
|
<p className="text-3xl font-bold">{beats.length}</p>
|
|
61
|
-
<p className="text-sm text-muted-foreground">Narrative
|
|
61
|
+
<p className="text-sm text-muted-foreground">Narrative beats</p>
|
|
62
62
|
</div>
|
|
63
63
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
64
|
-
<p className="text-3xl font-bold
|
|
65
|
-
<p className="text-sm text-muted-foreground">Relations
|
|
64
|
+
<p className="text-3xl font-bold" style={{ color: "var(--mw-wilson-high)" }}>{ceremoniedCount > 0 ? "✓" : "○"}</p>
|
|
65
|
+
<p className="text-sm text-muted-foreground">Relations ceremonied</p>
|
|
66
66
|
<p className="text-xs text-muted-foreground">{ceremoniedCount}/{edges.length} edges ceremonied</p>
|
|
67
67
|
</div>
|
|
68
68
|
</div>
|
|
69
69
|
|
|
70
70
|
{/* Frameworks */}
|
|
71
|
-
<h2 className="
|
|
71
|
+
<h2 className="mw-h2 mb-4">Indigenous research frameworks</h2>
|
|
72
72
|
<div className="space-y-3">
|
|
73
73
|
{resources.map((res) => (
|
|
74
74
|
<div key={res.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50"
|
package/app/api/edges/route.ts
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createProvider,
|
|
5
|
+
detectProvider,
|
|
6
|
+
EdgeNotFoundError,
|
|
7
|
+
} from "@medicine-wheel/storage-provider";
|
|
8
|
+
|
|
9
|
+
const EdgeCreateSchema = z.object({
|
|
10
|
+
from_id: z.string({ required_error: "from_id is required" }).trim().min(1),
|
|
11
|
+
to_id: z.string({ required_error: "to_id is required" }).trim().min(1),
|
|
12
|
+
relationship_type: z
|
|
13
|
+
.string({ required_error: "relationship_type is required" })
|
|
14
|
+
.trim()
|
|
15
|
+
.min(1, "cannot be empty"),
|
|
16
|
+
strength: z.number().min(0).max(1).optional(),
|
|
17
|
+
ceremony_honored: z.boolean().optional(),
|
|
18
|
+
obligations: z.array(z.string()).optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const EdgePatchSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
relationship_type: z.string().trim().min(1, "cannot be empty").optional(),
|
|
24
|
+
strength: z.number().min(0).max(1).optional(),
|
|
25
|
+
ceremony_honored: z.boolean().optional(),
|
|
26
|
+
obligations: z.array(z.string()).optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict()
|
|
29
|
+
.refine((patch) => Object.keys(patch).length > 0, {
|
|
30
|
+
message:
|
|
31
|
+
"Provide at least one field to change: relationship_type, strength, ceremony_honored, or obligations.",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Relations are identified by their pair of nodes (from → to).
|
|
36
|
+
* Accepts ?from=&to=, ?id=<from>:<to>, or from_id/to_id in the body.
|
|
37
|
+
*/
|
|
38
|
+
function resolvePair(
|
|
39
|
+
request: Request,
|
|
40
|
+
body: Record<string, unknown> | null,
|
|
41
|
+
): { from: string; to: string } | null {
|
|
42
|
+
const { searchParams } = new URL(request.url);
|
|
43
|
+
let from = searchParams.get("from");
|
|
44
|
+
let to = searchParams.get("to");
|
|
45
|
+
|
|
46
|
+
const composite = searchParams.get("id");
|
|
47
|
+
if ((!from || !to) && composite && composite.includes(":")) {
|
|
48
|
+
const separator = composite.indexOf(":");
|
|
49
|
+
from = composite.slice(0, separator);
|
|
50
|
+
to = composite.slice(separator + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ((!from || !to) && body) {
|
|
54
|
+
if (!from && typeof body.from_id === "string") from = body.from_id;
|
|
55
|
+
if (!to && typeof body.to_id === "string") to = body.to_id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return from && to ? { from, to } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pairRequired() {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{
|
|
64
|
+
error: "Relation not identified — nothing was changed.",
|
|
65
|
+
hint: "Identify the relation with ?from=<node-id>&to=<node-id> (or ?id=<from>:<to>).",
|
|
66
|
+
},
|
|
67
|
+
{ status: 400 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function relationNotFound(from: string, to: string) {
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{
|
|
74
|
+
error: `No relation exists from ${from} to ${to}.`,
|
|
75
|
+
hint: "Relations are directional — check the from/to order, or refresh the relations list.",
|
|
76
|
+
},
|
|
77
|
+
{ status: 404 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function unexpected(error: unknown) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
84
|
+
}
|
|
3
85
|
|
|
4
86
|
export async function GET() {
|
|
5
87
|
try {
|
|
@@ -7,30 +89,131 @@ export async function GET() {
|
|
|
7
89
|
const edges = await store.getAllEdges();
|
|
8
90
|
return NextResponse.json(edges);
|
|
9
91
|
} catch (error: unknown) {
|
|
10
|
-
|
|
11
|
-
return NextResponse.json({ error: message }, { status: 500 });
|
|
92
|
+
return unexpected(error);
|
|
12
93
|
}
|
|
13
94
|
}
|
|
14
95
|
|
|
15
96
|
export async function POST(request: Request) {
|
|
16
97
|
try {
|
|
98
|
+
const body = await request.json().catch(() => null);
|
|
99
|
+
const parsed = EdgeCreateSchema.safeParse(body);
|
|
100
|
+
if (!parsed.success) {
|
|
101
|
+
return NextResponse.json(
|
|
102
|
+
{
|
|
103
|
+
error: "Invalid relation — nothing was created.",
|
|
104
|
+
issues: parsed.error.issues.map(
|
|
105
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
{ status: 400 },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (parsed.data.from_id === parsed.data.to_id) {
|
|
113
|
+
return NextResponse.json(
|
|
114
|
+
{
|
|
115
|
+
error: "A relation needs two distinct nodes.",
|
|
116
|
+
hint: "Choose a different node for one end of the relation.",
|
|
117
|
+
},
|
|
118
|
+
{ status: 400 },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
17
122
|
const store = await createProvider();
|
|
18
|
-
|
|
19
|
-
|
|
123
|
+
|
|
124
|
+
// Relational accountability: both ends must exist before a thread is woven.
|
|
125
|
+
const [fromNode, toNode] = await Promise.all([
|
|
126
|
+
store.getNode(parsed.data.from_id),
|
|
127
|
+
store.getNode(parsed.data.to_id),
|
|
128
|
+
]);
|
|
129
|
+
const missing = [
|
|
130
|
+
...(fromNode ? [] : [parsed.data.from_id]),
|
|
131
|
+
...(toNode ? [] : [parsed.data.to_id]),
|
|
132
|
+
];
|
|
133
|
+
if (missing.length > 0) {
|
|
134
|
+
return NextResponse.json(
|
|
135
|
+
{
|
|
136
|
+
error: `Cannot weave a relation to a node that does not exist: ${missing.join(", ")}.`,
|
|
137
|
+
hint: "Create the node first on /nodes, then weave the relation.",
|
|
138
|
+
},
|
|
139
|
+
{ status: 404 },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const replaced = Boolean(await store.getEdge(parsed.data.from_id, parsed.data.to_id));
|
|
144
|
+
|
|
20
145
|
const edge = {
|
|
21
|
-
from_id:
|
|
22
|
-
to_id:
|
|
23
|
-
relationship_type:
|
|
24
|
-
strength:
|
|
25
|
-
ceremony_honored:
|
|
26
|
-
obligations:
|
|
146
|
+
from_id: parsed.data.from_id,
|
|
147
|
+
to_id: parsed.data.to_id,
|
|
148
|
+
relationship_type: parsed.data.relationship_type,
|
|
149
|
+
strength: parsed.data.strength ?? 0.5,
|
|
150
|
+
ceremony_honored: parsed.data.ceremony_honored ?? false,
|
|
151
|
+
obligations: parsed.data.obligations ?? [],
|
|
27
152
|
created_at: new Date().toISOString(),
|
|
28
153
|
};
|
|
29
|
-
|
|
154
|
+
|
|
30
155
|
await store.createEdge(edge);
|
|
31
|
-
return NextResponse.json(
|
|
156
|
+
return NextResponse.json(
|
|
157
|
+
{ success: true, edge, replaced, provider: detectProvider() },
|
|
158
|
+
{ status: 201 },
|
|
159
|
+
);
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
return unexpected(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function PATCH(request: Request) {
|
|
166
|
+
try {
|
|
167
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
168
|
+
const pair = resolvePair(request, body);
|
|
169
|
+
if (!pair) return pairRequired();
|
|
170
|
+
|
|
171
|
+
// from_id/to_id in the body only identify the relation — strip before validating the patch.
|
|
172
|
+
const patchBody = body ? { ...body } : {};
|
|
173
|
+
delete patchBody.from_id;
|
|
174
|
+
delete patchBody.to_id;
|
|
175
|
+
|
|
176
|
+
const parsed = EdgePatchSchema.safeParse(patchBody);
|
|
177
|
+
if (!parsed.success) {
|
|
178
|
+
return NextResponse.json(
|
|
179
|
+
{
|
|
180
|
+
error: "Invalid relation patch — nothing was changed.",
|
|
181
|
+
issues: parsed.error.issues.map(
|
|
182
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
{ status: 400 },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const store = await createProvider();
|
|
190
|
+
const edge = await store.updateEdge(pair.from, pair.to, parsed.data);
|
|
191
|
+
return NextResponse.json({ success: true, edge, provider: detectProvider() });
|
|
192
|
+
} catch (error: unknown) {
|
|
193
|
+
if (error instanceof EdgeNotFoundError) {
|
|
194
|
+
return relationNotFound(error.fromId, error.toId);
|
|
195
|
+
}
|
|
196
|
+
return unexpected(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function DELETE(request: Request) {
|
|
201
|
+
try {
|
|
202
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
203
|
+
const pair = resolvePair(request, body);
|
|
204
|
+
if (!pair) return pairRequired();
|
|
205
|
+
|
|
206
|
+
const store = await createProvider();
|
|
207
|
+
await store.deleteEdge(pair.from, pair.to);
|
|
208
|
+
return NextResponse.json({
|
|
209
|
+
success: true,
|
|
210
|
+
deleted: { from_id: pair.from, to_id: pair.to },
|
|
211
|
+
provider: detectProvider(),
|
|
212
|
+
});
|
|
32
213
|
} catch (error: unknown) {
|
|
33
|
-
|
|
34
|
-
|
|
214
|
+
if (error instanceof EdgeNotFoundError) {
|
|
215
|
+
return relationNotFound(error.fromId, error.toId);
|
|
216
|
+
}
|
|
217
|
+
return unexpected(error);
|
|
35
218
|
}
|
|
36
219
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DirectionNameSchema, NodeTypeSchema } from "@medicine-wheel/ontology-core";
|
|
4
|
+
import {
|
|
5
|
+
createProvider,
|
|
6
|
+
detectProvider,
|
|
7
|
+
NodeNotFoundError,
|
|
8
|
+
NodeHasRelationsError,
|
|
9
|
+
} from "@medicine-wheel/storage-provider";
|
|
10
|
+
|
|
11
|
+
// `direction: null` releases the node from its direction.
|
|
12
|
+
const NodePatchSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
name: z.string().trim().min(1, "cannot be empty").optional(),
|
|
15
|
+
type: NodeTypeSchema.optional(),
|
|
16
|
+
description: z.string().optional(),
|
|
17
|
+
direction: DirectionNameSchema.nullable().optional(),
|
|
18
|
+
metadata: z.record(z.unknown()).optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict()
|
|
21
|
+
.refine((patch) => Object.keys(patch).length > 0, {
|
|
22
|
+
message:
|
|
23
|
+
"Provide at least one field to change: name, type, description, direction, or metadata.",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
type RouteContext = { params: Promise<{ id: string }> };
|
|
27
|
+
|
|
28
|
+
function notFound(id: string) {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{
|
|
31
|
+
error: `Node not found: ${id}`,
|
|
32
|
+
hint: "It may already be released — refresh the nodes list.",
|
|
33
|
+
},
|
|
34
|
+
{ status: 404 },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unexpected(error: unknown) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function GET(_request: Request, context: RouteContext) {
|
|
44
|
+
try {
|
|
45
|
+
const { id } = await context.params;
|
|
46
|
+
const store = await createProvider();
|
|
47
|
+
const node = await store.getNode(id);
|
|
48
|
+
if (!node) return notFound(id);
|
|
49
|
+
return NextResponse.json({ node, provider: detectProvider() });
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
return unexpected(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function PATCH(request: Request, context: RouteContext) {
|
|
56
|
+
try {
|
|
57
|
+
const { id } = await context.params;
|
|
58
|
+
const body = await request.json().catch(() => null);
|
|
59
|
+
const parsed = NodePatchSchema.safeParse(body);
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{
|
|
63
|
+
error: "Invalid node patch — nothing was changed.",
|
|
64
|
+
issues: parsed.error.issues.map(
|
|
65
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
{ status: 400 },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const store = await createProvider();
|
|
73
|
+
const node = await store.updateNode(id, parsed.data);
|
|
74
|
+
return NextResponse.json({ success: true, node, provider: detectProvider() });
|
|
75
|
+
} catch (error: unknown) {
|
|
76
|
+
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
|
77
|
+
return unexpected(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function DELETE(_request: Request, context: RouteContext) {
|
|
82
|
+
try {
|
|
83
|
+
const { id } = await context.params;
|
|
84
|
+
const store = await createProvider();
|
|
85
|
+
await store.deleteNode(id);
|
|
86
|
+
return NextResponse.json({ success: true, deleted: id, provider: detectProvider() });
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
|
89
|
+
if (error instanceof NodeHasRelationsError) {
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{
|
|
92
|
+
error: error.message,
|
|
93
|
+
relation_count: error.relationCount,
|
|
94
|
+
hint: "Release its relations on /relations, then delete the node.",
|
|
95
|
+
},
|
|
96
|
+
{ status: 409 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return unexpected(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/app/api/nodes/route.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DirectionNameSchema, NodeTypeSchema } from "@medicine-wheel/ontology-core";
|
|
2
4
|
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
5
|
|
|
6
|
+
const NodeCreateSchema = z.object({
|
|
7
|
+
id: z.string().trim().min(1).optional(),
|
|
8
|
+
name: z.string({ required_error: "name is required" }).trim().min(1, "cannot be empty"),
|
|
9
|
+
type: NodeTypeSchema,
|
|
10
|
+
description: z.string().optional(),
|
|
11
|
+
// Forms send "" when no direction is chosen — treat that as "no direction".
|
|
12
|
+
direction: z.preprocess(
|
|
13
|
+
(value) => (value === "" || value === null ? undefined : value),
|
|
14
|
+
DirectionNameSchema.optional(),
|
|
15
|
+
),
|
|
16
|
+
metadata: z.record(z.unknown()).optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
4
19
|
export async function GET(request: Request) {
|
|
5
20
|
try {
|
|
6
21
|
const { searchParams } = new URL(request.url);
|
|
@@ -16,10 +31,10 @@ export async function GET(request: Request) {
|
|
|
16
31
|
nodes = nodes.filter((n) => n.direction === direction);
|
|
17
32
|
}
|
|
18
33
|
|
|
19
|
-
return NextResponse.json({
|
|
20
|
-
nodes,
|
|
34
|
+
return NextResponse.json({
|
|
35
|
+
nodes,
|
|
21
36
|
provider: detectProvider(),
|
|
22
|
-
count: nodes.length
|
|
37
|
+
count: nodes.length
|
|
23
38
|
});
|
|
24
39
|
} catch (error: unknown) {
|
|
25
40
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -29,20 +44,32 @@ export async function GET(request: Request) {
|
|
|
29
44
|
|
|
30
45
|
export async function POST(request: Request) {
|
|
31
46
|
try {
|
|
47
|
+
const body = await request.json().catch(() => null);
|
|
48
|
+
const parsed = NodeCreateSchema.safeParse(body);
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{
|
|
52
|
+
error: "Invalid node — nothing was created.",
|
|
53
|
+
issues: parsed.error.issues.map(
|
|
54
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{ status: 400 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
const store = await createProvider();
|
|
33
|
-
const body = await request.json();
|
|
34
|
-
|
|
35
62
|
const node = {
|
|
36
|
-
id:
|
|
37
|
-
name:
|
|
38
|
-
type:
|
|
39
|
-
description:
|
|
40
|
-
direction:
|
|
41
|
-
metadata:
|
|
63
|
+
id: parsed.data.id || crypto.randomUUID(),
|
|
64
|
+
name: parsed.data.name,
|
|
65
|
+
type: parsed.data.type,
|
|
66
|
+
description: parsed.data.description || "",
|
|
67
|
+
direction: parsed.data.direction,
|
|
68
|
+
metadata: parsed.data.metadata || {},
|
|
42
69
|
created_at: new Date().toISOString(),
|
|
43
70
|
updated_at: new Date().toISOString(),
|
|
44
71
|
};
|
|
45
|
-
|
|
72
|
+
|
|
46
73
|
await store.createNode(node);
|
|
47
74
|
return NextResponse.json({ success: true, node, provider: detectProvider() }, { status: 201 });
|
|
48
75
|
} catch (error: unknown) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
|
+
|
|
4
|
+
export async function GET(
|
|
5
|
+
_request: Request,
|
|
6
|
+
context: { params: Promise<{ id: string }> },
|
|
7
|
+
) {
|
|
8
|
+
try {
|
|
9
|
+
const params = await context.params;
|
|
10
|
+
const store = await createProvider();
|
|
11
|
+
const record = await store.getPlanPerspective(params.id);
|
|
12
|
+
|
|
13
|
+
if (!record) {
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: `Plan Perspective '${params.id}' was not found` },
|
|
16
|
+
{ status: 404 },
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return NextResponse.json({
|
|
21
|
+
record,
|
|
22
|
+
provider: detectProvider(),
|
|
23
|
+
});
|
|
24
|
+
} catch (error: unknown) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
createProvider,
|
|
4
|
+
detectProvider,
|
|
5
|
+
type PlanPerspectiveFilters,
|
|
6
|
+
type PlanPerspectiveRecord,
|
|
7
|
+
} from "@medicine-wheel/storage-provider";
|
|
8
|
+
|
|
9
|
+
const PERSPECTIVE_ID_PREFIX = "plan-perspective:";
|
|
10
|
+
|
|
11
|
+
export async function GET(request: Request) {
|
|
12
|
+
try {
|
|
13
|
+
const { searchParams } = new URL(request.url);
|
|
14
|
+
const filters = planPerspectiveFiltersFromSearchParams(searchParams);
|
|
15
|
+
|
|
16
|
+
if (
|
|
17
|
+
filters.episode_path === undefined &&
|
|
18
|
+
filters.session_id === undefined &&
|
|
19
|
+
filters.id === undefined
|
|
20
|
+
) {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: "Plan Perspective queries require episode_path, session_id, or id" },
|
|
23
|
+
{ status: 400 },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const store = await createProvider();
|
|
28
|
+
const planPerspectives = await store.listPlanPerspectives(filters);
|
|
29
|
+
|
|
30
|
+
return NextResponse.json({
|
|
31
|
+
plan_perspectives: planPerspectives,
|
|
32
|
+
provider: detectProvider(),
|
|
33
|
+
count: planPerspectives.length,
|
|
34
|
+
});
|
|
35
|
+
} catch (error: unknown) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function POST(request: Request) {
|
|
42
|
+
let record: PlanPerspectiveRecord;
|
|
43
|
+
try {
|
|
44
|
+
record = (await request.json()) as PlanPerspectiveRecord;
|
|
45
|
+
} catch {
|
|
46
|
+
return NextResponse.json(
|
|
47
|
+
{ error: "Plan Perspective registration requires a JSON body" },
|
|
48
|
+
{ status: 400 },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validationError = validatePerspectiveRecord(record);
|
|
53
|
+
if (validationError) {
|
|
54
|
+
return NextResponse.json({ error: validationError }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const store = await createProvider();
|
|
59
|
+
const merged = await store.registerPlanPerspective(record);
|
|
60
|
+
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{
|
|
63
|
+
success: true,
|
|
64
|
+
record: merged,
|
|
65
|
+
provider: detectProvider(),
|
|
66
|
+
},
|
|
67
|
+
{ status: 201 },
|
|
68
|
+
);
|
|
69
|
+
} catch (error: unknown) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function planPerspectiveFiltersFromSearchParams(
|
|
76
|
+
searchParams: URLSearchParams,
|
|
77
|
+
): PlanPerspectiveFilters {
|
|
78
|
+
const filters: PlanPerspectiveFilters = {};
|
|
79
|
+
const episodePath = searchParams.get("episode_path");
|
|
80
|
+
const sessionId = searchParams.get("session_id");
|
|
81
|
+
const id = searchParams.get("id");
|
|
82
|
+
|
|
83
|
+
if (episodePath) filters.episode_path = episodePath;
|
|
84
|
+
if (sessionId) filters.session_id = sessionId;
|
|
85
|
+
if (id) filters.id = id;
|
|
86
|
+
|
|
87
|
+
return filters;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
91
|
+
return typeof value === "string" && value.length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Validates the PerspectiveRecord spine; unknown fields pass through untouched. */
|
|
99
|
+
function validatePerspectiveRecord(record: unknown): string | null {
|
|
100
|
+
if (!isPlainObject(record)) {
|
|
101
|
+
return "Plan Perspective registration requires a PerspectiveRecord object";
|
|
102
|
+
}
|
|
103
|
+
if (!isNonEmptyString(record.id) || !record.id.startsWith(PERSPECTIVE_ID_PREFIX)) {
|
|
104
|
+
return `Plan Perspective id must be a non-empty string starting with '${PERSPECTIVE_ID_PREFIX}'`;
|
|
105
|
+
}
|
|
106
|
+
if (record.perspective !== 1) {
|
|
107
|
+
return "Plan Perspective record requires perspective: 1";
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
!isPlainObject(record.plan) ||
|
|
111
|
+
!isNonEmptyString(record.plan.session_id) ||
|
|
112
|
+
!isNonEmptyString(record.plan.plan_filename) ||
|
|
113
|
+
!isNonEmptyString(record.plan.plan_sha256)
|
|
114
|
+
) {
|
|
115
|
+
return "Plan Perspective record requires plan.session_id, plan.plan_filename, and plan.plan_sha256";
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
!isPlainObject(record.narrative) ||
|
|
119
|
+
!isNonEmptyString(record.narrative.title) ||
|
|
120
|
+
typeof record.narrative.body_markdown !== "string"
|
|
121
|
+
) {
|
|
122
|
+
return "Plan Perspective record requires narrative.title and narrative.body_markdown";
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
!Array.isArray(record.episodes) ||
|
|
126
|
+
record.episodes.some((episode) => !isPlainObject(episode) || !isNonEmptyString(episode.path))
|
|
127
|
+
) {
|
|
128
|
+
return "Plan Perspective episodes must be an array of { path } relations (may be empty)";
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
!isPlainObject(record.source) ||
|
|
132
|
+
!isNonEmptyString(record.source.registered_at) ||
|
|
133
|
+
!isNonEmptyString(record.source.updated_at)
|
|
134
|
+
) {
|
|
135
|
+
return "Plan Perspective record requires source.registered_at and source.updated_at";
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|