@lightupai/polaris 0.0.77 → 0.0.78
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/.github/workflows/cd.yml +77 -0
- package/docs/architecture.md +203 -0
- package/docs/product-critique-2026-06.md +136 -0
- package/docs/readiness-review-2026-06.md +130 -0
- package/hooks/capture-prompt.ts +60 -0
- package/hooks/capture-stop.ts +10 -3
- package/hooks/capture.sh +12 -0
- package/hooks/statusline.sh +8 -2
- package/package.json +1 -1
- package/scripts/backup.sh +71 -0
- package/src/bridge-discover-org.ts +30 -4
- package/src/cli/cli.ts +304 -34
- package/src/client/client.ts +44 -11
- package/src/daemon/daemon.ts +262 -38
- package/src/service/auth.ts +10 -1
- package/src/service/db.ts +338 -63
- package/src/service/server.ts +267 -11
- package/src/slack/bridge.ts +172 -34
- package/src/types.ts +22 -0
- package/src/web/app.ts +373 -2
- package/src/web/layout.ts +5 -1
- package/src/web/views.ts +420 -6
- package/tests/acl.test.ts +100 -0
- package/tests/annotations.test.ts +209 -0
- package/tests/bridge-discover.test.ts +70 -0
- package/tests/daemon-auth.test.ts +123 -0
- package/tests/event-lookup.test.ts +113 -0
- package/tests/label.test.ts +90 -0
- package/tests/pagination.test.ts +165 -0
- package/tests/recover.test.ts +83 -0
- package/tests/reply-attribution.test.ts +103 -0
- package/tests/search-acl.test.ts +117 -0
- package/tests/search.test.ts +145 -0
package/src/web/app.ts
CHANGED
|
@@ -21,7 +21,20 @@ import {
|
|
|
21
21
|
type Sql,
|
|
22
22
|
} from "../service/db";
|
|
23
23
|
import { layout, nav } from "./layout";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
renderSetupView,
|
|
26
|
+
renderActiveView,
|
|
27
|
+
renderProfileView,
|
|
28
|
+
renderErrorView,
|
|
29
|
+
renderTranscriptView,
|
|
30
|
+
renderSearchView,
|
|
31
|
+
renderDecisionsView,
|
|
32
|
+
renderProjectSettingsView,
|
|
33
|
+
type TranscriptEvent,
|
|
34
|
+
type SearchResult,
|
|
35
|
+
type ProjectMemberView,
|
|
36
|
+
} from "./views";
|
|
37
|
+
import { AnnotationKind, type Annotation } from "../types";
|
|
25
38
|
import { renderLandingPage } from "./pages";
|
|
26
39
|
import { createSystemChannel, postSystemEvent } from "../slack/system";
|
|
27
40
|
import {
|
|
@@ -137,6 +150,9 @@ setInterval(() => {
|
|
|
137
150
|
export function createApp(sql: Sql) {
|
|
138
151
|
const app = new Hono();
|
|
139
152
|
|
|
153
|
+
// Polaris API service (same convention as slack/bridge.ts)
|
|
154
|
+
const apiUrl = process.env.POLARIS_API_URL ?? "http://localhost:4321";
|
|
155
|
+
|
|
140
156
|
// Start hourly signup rollup
|
|
141
157
|
startSignupRollup(sql);
|
|
142
158
|
|
|
@@ -343,13 +359,14 @@ export function createApp(sql: Sql) {
|
|
|
343
359
|
const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
|
|
344
360
|
const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
|
|
345
361
|
const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
|
|
362
|
+
const participantId = payload.participant_id;
|
|
346
363
|
|
|
347
364
|
function buildSessionFixture(s: typeof allSessions[0]): import("./fixtures").SessionFixture {
|
|
348
365
|
return {
|
|
349
366
|
name: s.name,
|
|
350
367
|
project: s.project,
|
|
351
368
|
driver: s.driver ?? "",
|
|
352
|
-
role: (s.driver ===
|
|
369
|
+
role: (s.driver === participantId ? "driver" : "advisor") as "driver" | "advisor",
|
|
353
370
|
description: "",
|
|
354
371
|
participants: s.driver ? [{ id: s.driver, role: "driver" as const }] : [],
|
|
355
372
|
eventCount: promptCounts.get(`${s.project}/${s.name}`) ?? 0,
|
|
@@ -409,11 +426,365 @@ export function createApp(sql: Sql) {
|
|
|
409
426
|
slackConnected: !!org.slack_team_id,
|
|
410
427
|
cliInstalled: false,
|
|
411
428
|
hasConnectedSession: false,
|
|
429
|
+
totalPrompts: 0,
|
|
412
430
|
};
|
|
413
431
|
|
|
414
432
|
return layout(renderProfileView(ctx, payload.participant_id), "Polaris - Profile");
|
|
415
433
|
});
|
|
416
434
|
|
|
435
|
+
// --- Session transcript ---
|
|
436
|
+
|
|
437
|
+
app.get("/sessions/:proj/:sess", async (c) => {
|
|
438
|
+
const token = c.req.query("token");
|
|
439
|
+
if (!token) return c.redirect("/login");
|
|
440
|
+
|
|
441
|
+
const payload = await verifyToken(token);
|
|
442
|
+
if (!payload) return c.redirect("/login");
|
|
443
|
+
|
|
444
|
+
const org = await getOrg(sql, payload.org_id);
|
|
445
|
+
if (!org) return c.redirect("/login");
|
|
446
|
+
|
|
447
|
+
const proj = c.req.param("proj");
|
|
448
|
+
const sess = c.req.param("sess");
|
|
449
|
+
const before = c.req.query("before");
|
|
450
|
+
|
|
451
|
+
const eventsUrl = new URL(`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/events`);
|
|
452
|
+
eventsUrl.searchParams.set("limit", "200");
|
|
453
|
+
if (before) eventsUrl.searchParams.set("before", before);
|
|
454
|
+
|
|
455
|
+
let events: TranscriptEvent[];
|
|
456
|
+
let nextCursor: string | null;
|
|
457
|
+
try {
|
|
458
|
+
const res = await fetch(eventsUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
459
|
+
if (!res.ok) {
|
|
460
|
+
const message = res.status === 404 ? "Session not found." : `Failed to load transcript (${res.status}).`;
|
|
461
|
+
return layout(renderErrorView(message, "Back to dashboard", `/dashboard?token=${token}`));
|
|
462
|
+
}
|
|
463
|
+
const data = (await res.json()) as { events: TranscriptEvent[]; nextCursor: string | null };
|
|
464
|
+
events = data.events ?? [];
|
|
465
|
+
nextCursor = data.nextCursor ?? null;
|
|
466
|
+
} catch {
|
|
467
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to dashboard", `/dashboard?token=${token}`));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Annotations are progressive enhancement — render the transcript even if they fail to load.
|
|
471
|
+
let annotations: Annotation[] = [];
|
|
472
|
+
try {
|
|
473
|
+
const annRes = await fetch(
|
|
474
|
+
`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/annotations`,
|
|
475
|
+
{ headers: { Authorization: `Bearer ${token}` } }
|
|
476
|
+
);
|
|
477
|
+
if (annRes.ok) {
|
|
478
|
+
annotations = ((await annRes.json()) as { annotations: Annotation[] }).annotations ?? [];
|
|
479
|
+
}
|
|
480
|
+
} catch { /* transcript still renders without annotations */ }
|
|
481
|
+
|
|
482
|
+
const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
|
|
483
|
+
const title = `Polaris - ${proj}/${sess}`.replace(/[<>&"]/g, "");
|
|
484
|
+
return layout(renderTranscriptView(ctx, proj, sess, events, nextCursor, before, annotations), title);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Proxies to the API inject endpoint; the API derives the sender from the bearer token.
|
|
488
|
+
app.post("/sessions/:proj/:sess/inject", async (c) => {
|
|
489
|
+
const token = c.req.query("token");
|
|
490
|
+
if (!token) return c.redirect("/login");
|
|
491
|
+
|
|
492
|
+
const payload = await verifyToken(token);
|
|
493
|
+
if (!payload) return c.redirect("/login");
|
|
494
|
+
|
|
495
|
+
const proj = c.req.param("proj");
|
|
496
|
+
const sess = c.req.param("sess");
|
|
497
|
+
const transcriptUrl = `/sessions/${encodeURIComponent(proj)}/${encodeURIComponent(sess)}?token=${token}`;
|
|
498
|
+
|
|
499
|
+
const body = await c.req.parseBody();
|
|
500
|
+
const content = typeof body.content === "string" ? body.content.trim() : "";
|
|
501
|
+
if (!content) return c.redirect(transcriptUrl);
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/inject`, {
|
|
505
|
+
method: "POST",
|
|
506
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
507
|
+
body: JSON.stringify({ content }),
|
|
508
|
+
});
|
|
509
|
+
if (!res.ok) {
|
|
510
|
+
return layout(renderErrorView(`Inject failed (${res.status}).`, "Back to transcript", transcriptUrl));
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to transcript", transcriptUrl));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return c.redirect(transcriptUrl);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// --- Annotations (star / tag / decision curation) ---
|
|
520
|
+
// Proxies to the API annotations endpoints; the API derives the participant from the bearer token.
|
|
521
|
+
|
|
522
|
+
app.post("/sessions/:proj/:sess/annotations", async (c) => {
|
|
523
|
+
const token = c.req.query("token");
|
|
524
|
+
if (!token) return c.redirect("/login");
|
|
525
|
+
|
|
526
|
+
const payload = await verifyToken(token);
|
|
527
|
+
if (!payload) return c.redirect("/login");
|
|
528
|
+
|
|
529
|
+
const proj = c.req.param("proj");
|
|
530
|
+
const sess = c.req.param("sess");
|
|
531
|
+
const transcriptUrl = `/sessions/${encodeURIComponent(proj)}/${encodeURIComponent(sess)}?token=${token}`;
|
|
532
|
+
|
|
533
|
+
const body = await c.req.parseBody();
|
|
534
|
+
const kind = typeof body.kind === "string" ? body.kind : "";
|
|
535
|
+
if (!AnnotationKind.safeParse(kind).success) return c.redirect(transcriptUrl);
|
|
536
|
+
const eventId = typeof body.event_id === "string" && body.event_id ? body.event_id : undefined;
|
|
537
|
+
const value = typeof body.value === "string" && body.value.trim() ? body.value.trim() : undefined;
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/annotations`, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
543
|
+
body: JSON.stringify({ event_id: eventId, kind, value }),
|
|
544
|
+
});
|
|
545
|
+
if (!res.ok) {
|
|
546
|
+
return layout(renderErrorView(`Annotation failed (${res.status}).`, "Back to transcript", transcriptUrl));
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to transcript", transcriptUrl));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return c.redirect(transcriptUrl);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
app.post("/sessions/:proj/:sess/annotations/:id/delete", async (c) => {
|
|
556
|
+
const token = c.req.query("token");
|
|
557
|
+
if (!token) return c.redirect("/login");
|
|
558
|
+
|
|
559
|
+
const payload = await verifyToken(token);
|
|
560
|
+
if (!payload) return c.redirect("/login");
|
|
561
|
+
|
|
562
|
+
const proj = c.req.param("proj");
|
|
563
|
+
const sess = c.req.param("sess");
|
|
564
|
+
const id = c.req.param("id");
|
|
565
|
+
const transcriptUrl = `/sessions/${encodeURIComponent(proj)}/${encodeURIComponent(sess)}?token=${token}`;
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const res = await fetch(`${apiUrl}/annotations/${encodeURIComponent(id)}`, {
|
|
569
|
+
method: "DELETE",
|
|
570
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
571
|
+
});
|
|
572
|
+
if (!res.ok) {
|
|
573
|
+
return layout(renderErrorView(`Failed to remove annotation (${res.status}).`, "Back to transcript", transcriptUrl));
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to transcript", transcriptUrl));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return c.redirect(transcriptUrl);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// --- Decisions (org-wide curation feed) ---
|
|
583
|
+
|
|
584
|
+
app.get("/decisions", async (c) => {
|
|
585
|
+
const token = c.req.query("token");
|
|
586
|
+
if (!token) return c.redirect("/login");
|
|
587
|
+
|
|
588
|
+
const payload = await verifyToken(token);
|
|
589
|
+
if (!payload) return c.redirect("/login");
|
|
590
|
+
|
|
591
|
+
const org = await getOrg(sql, payload.org_id);
|
|
592
|
+
if (!org) return c.redirect("/login");
|
|
593
|
+
|
|
594
|
+
const project = (c.req.query("project") ?? "").trim();
|
|
595
|
+
|
|
596
|
+
let decisions: Annotation[] | null = null;
|
|
597
|
+
let decisionsError: string | undefined;
|
|
598
|
+
const decisionsUrl = new URL(`${apiUrl}/decisions`);
|
|
599
|
+
if (project) decisionsUrl.searchParams.set("project", project);
|
|
600
|
+
decisionsUrl.searchParams.set("limit", "100");
|
|
601
|
+
try {
|
|
602
|
+
const res = await fetch(decisionsUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
603
|
+
if (res.ok) {
|
|
604
|
+
decisions = ((await res.json()) as { decisions: Annotation[] }).decisions ?? [];
|
|
605
|
+
} else {
|
|
606
|
+
decisionsError = `Failed to load decisions (${res.status}).`;
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
decisionsError = "Could not reach the Polaris API.";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
|
|
613
|
+
return layout(renderDecisionsView(ctx, project, decisions, decisionsError), "Polaris - Decisions");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// --- Project settings (visibility + members) ---
|
|
617
|
+
|
|
618
|
+
app.get("/projects/:proj/settings", async (c) => {
|
|
619
|
+
const token = c.req.query("token");
|
|
620
|
+
if (!token) return c.redirect("/login");
|
|
621
|
+
|
|
622
|
+
const payload = await verifyToken(token);
|
|
623
|
+
if (!payload) return c.redirect("/login");
|
|
624
|
+
|
|
625
|
+
const org = await getOrg(sql, payload.org_id);
|
|
626
|
+
if (!org) return c.redirect("/login");
|
|
627
|
+
|
|
628
|
+
const proj = c.req.param("proj");
|
|
629
|
+
const projects = await listProjects(sql, payload.org_id);
|
|
630
|
+
const project = projects.find((p) => p.name === proj);
|
|
631
|
+
if (!project) {
|
|
632
|
+
return layout(renderErrorView("Project not found.", "Back to dashboard", `/dashboard?token=${token}`));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let members: ProjectMemberView[] = [];
|
|
636
|
+
let membersError: string | undefined;
|
|
637
|
+
try {
|
|
638
|
+
const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/members`, {
|
|
639
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
640
|
+
});
|
|
641
|
+
if (res.ok) {
|
|
642
|
+
const data = (await res.json()) as ProjectMemberView[] | { members?: ProjectMemberView[] };
|
|
643
|
+
members = Array.isArray(data) ? data : data.members ?? [];
|
|
644
|
+
} else {
|
|
645
|
+
membersError = `Failed to load members (${res.status}).`;
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
membersError = "Could not reach the Polaris API.";
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
|
|
652
|
+
const title = `Polaris - ${proj} settings`.replace(/[<>&"]/g, "");
|
|
653
|
+
return layout(renderProjectSettingsView(ctx, proj, project.visibility ?? "org", members, membersError), title);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
app.post("/projects/:proj/visibility", async (c) => {
|
|
657
|
+
const token = c.req.query("token");
|
|
658
|
+
if (!token) return c.redirect("/login");
|
|
659
|
+
|
|
660
|
+
const payload = await verifyToken(token);
|
|
661
|
+
if (!payload) return c.redirect("/login");
|
|
662
|
+
|
|
663
|
+
const proj = c.req.param("proj");
|
|
664
|
+
const settingsUrl = `/projects/${encodeURIComponent(proj)}/settings?token=${token}`;
|
|
665
|
+
|
|
666
|
+
const body = await c.req.parseBody();
|
|
667
|
+
const visibility = typeof body.visibility === "string" ? body.visibility : "";
|
|
668
|
+
if (visibility !== "org" && visibility !== "members") return c.redirect(settingsUrl);
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/visibility`, {
|
|
672
|
+
method: "POST",
|
|
673
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
674
|
+
body: JSON.stringify({ visibility }),
|
|
675
|
+
});
|
|
676
|
+
if (!res.ok) {
|
|
677
|
+
return layout(renderErrorView(`Failed to update visibility (${res.status}).`, "Back to settings", settingsUrl));
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to settings", settingsUrl));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return c.redirect(settingsUrl);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
app.post("/projects/:proj/members", async (c) => {
|
|
687
|
+
const token = c.req.query("token");
|
|
688
|
+
if (!token) return c.redirect("/login");
|
|
689
|
+
|
|
690
|
+
const payload = await verifyToken(token);
|
|
691
|
+
if (!payload) return c.redirect("/login");
|
|
692
|
+
|
|
693
|
+
const proj = c.req.param("proj");
|
|
694
|
+
const settingsUrl = `/projects/${encodeURIComponent(proj)}/settings?token=${token}`;
|
|
695
|
+
|
|
696
|
+
const body = await c.req.parseBody();
|
|
697
|
+
const participantId = typeof body.participant_id === "string" ? body.participant_id.trim() : "";
|
|
698
|
+
if (!participantId) return c.redirect(settingsUrl);
|
|
699
|
+
const role = typeof body.role === "string" && body.role.trim() ? body.role.trim() : undefined;
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/members`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
705
|
+
body: JSON.stringify({ participant_id: participantId, role }),
|
|
706
|
+
});
|
|
707
|
+
if (!res.ok) {
|
|
708
|
+
return layout(renderErrorView(`Failed to add member (${res.status}).`, "Back to settings", settingsUrl));
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to settings", settingsUrl));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return c.redirect(settingsUrl);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
app.post("/projects/:proj/members/:pid/delete", async (c) => {
|
|
718
|
+
const token = c.req.query("token");
|
|
719
|
+
if (!token) return c.redirect("/login");
|
|
720
|
+
|
|
721
|
+
const payload = await verifyToken(token);
|
|
722
|
+
if (!payload) return c.redirect("/login");
|
|
723
|
+
|
|
724
|
+
const proj = c.req.param("proj");
|
|
725
|
+
const pid = c.req.param("pid");
|
|
726
|
+
const settingsUrl = `/projects/${encodeURIComponent(proj)}/settings?token=${token}`;
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/members/${encodeURIComponent(pid)}`, {
|
|
730
|
+
method: "DELETE",
|
|
731
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
732
|
+
});
|
|
733
|
+
if (!res.ok) {
|
|
734
|
+
return layout(renderErrorView(`Failed to remove member (${res.status}).`, "Back to settings", settingsUrl));
|
|
735
|
+
}
|
|
736
|
+
} catch {
|
|
737
|
+
return layout(renderErrorView("Could not reach the Polaris API.", "Back to settings", settingsUrl));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return c.redirect(settingsUrl);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// --- Search ---
|
|
744
|
+
|
|
745
|
+
app.get("/search", async (c) => {
|
|
746
|
+
const token = c.req.query("token");
|
|
747
|
+
if (!token) return c.redirect("/login");
|
|
748
|
+
|
|
749
|
+
const payload = await verifyToken(token);
|
|
750
|
+
if (!payload) return c.redirect("/login");
|
|
751
|
+
|
|
752
|
+
const org = await getOrg(sql, payload.org_id);
|
|
753
|
+
if (!org) return c.redirect("/login");
|
|
754
|
+
|
|
755
|
+
const query = {
|
|
756
|
+
q: (c.req.query("q") ?? "").trim(),
|
|
757
|
+
project: c.req.query("project") ?? "",
|
|
758
|
+
sender: c.req.query("sender") ?? "",
|
|
759
|
+
source: c.req.query("source") ?? "",
|
|
760
|
+
tag: (c.req.query("tag") ?? "").trim(),
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
let results: SearchResult[] | null = null;
|
|
764
|
+
let searchError: string | undefined;
|
|
765
|
+
if (query.q) {
|
|
766
|
+
const searchUrl = new URL(`${apiUrl}/search`);
|
|
767
|
+
searchUrl.searchParams.set("q", query.q);
|
|
768
|
+
if (query.project) searchUrl.searchParams.set("project", query.project);
|
|
769
|
+
if (query.sender) searchUrl.searchParams.set("sender", query.sender);
|
|
770
|
+
if (query.source) searchUrl.searchParams.set("source", query.source);
|
|
771
|
+
if (query.tag) searchUrl.searchParams.set("tag", query.tag);
|
|
772
|
+
try {
|
|
773
|
+
const res = await fetch(searchUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
774
|
+
if (res.ok) {
|
|
775
|
+
results = ((await res.json()) as { results: SearchResult[] }).results ?? [];
|
|
776
|
+
} else {
|
|
777
|
+
searchError = `Search failed (${res.status}).`;
|
|
778
|
+
}
|
|
779
|
+
} catch {
|
|
780
|
+
searchError = "Could not reach the Polaris API.";
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
|
|
785
|
+
return layout(renderSearchView(ctx, query, results, searchError), "Polaris - Search");
|
|
786
|
+
});
|
|
787
|
+
|
|
417
788
|
// --- Preview (dev only — all view states on one page) ---
|
|
418
789
|
|
|
419
790
|
app.get("/preview", (c) => {
|
package/src/web/layout.ts
CHANGED
|
@@ -86,7 +86,9 @@ document.addEventListener('click', function(e) {
|
|
|
86
86
|
|
|
87
87
|
export function nav(token?: string, opts?: NavOpts): string {
|
|
88
88
|
const right = token
|
|
89
|
-
? `<
|
|
89
|
+
? `<a href="/decisions?token=${token}" class="text-sm font-medium text-gray-600 hover:text-gray-900">Decisions</a>
|
|
90
|
+
<a href="/search?token=${token}" class="text-sm font-medium text-gray-600 hover:text-gray-900">Search</a>
|
|
91
|
+
<div class="relative group">
|
|
90
92
|
<button class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-100 transition">
|
|
91
93
|
<div class="w-7 h-7 rounded-full bg-polaris-600 flex items-center justify-center text-white text-xs font-bold">${(opts?.userName ?? "U").charAt(0).toUpperCase()}</div>
|
|
92
94
|
<div class="text-left">
|
|
@@ -102,6 +104,8 @@ export function nav(token?: string, opts?: NavOpts): string {
|
|
|
102
104
|
<p class="text-[10px] text-gray-400">${opts?.orgName ?? ""}</p>
|
|
103
105
|
</div>
|
|
104
106
|
<a href="/dashboard?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Dashboard</a>
|
|
107
|
+
<a href="/decisions?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Decisions</a>
|
|
108
|
+
<a href="/search?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Search</a>
|
|
105
109
|
<a href="/profile?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Profile</a>
|
|
106
110
|
<div class="border-t border-gray-100"></div>
|
|
107
111
|
<a href="/" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Log out</a>
|