1
0
Fork 0

feat(profile): add rich-text dossier excerpt editors

Convert the dossier personal excerpt from a plain textarea to Lexical
rich text (matching the intel excerpt) with a 1000-character plain-text
limit, and replace the static read-only cards on the profile page with
inline DossierExcerptEditors: owners edit their personal excerpt,
profiles:intel_excerpt:update holders edit the intelligence record.
Changes are validated server-side (lexicalPlaintextLength) and emit
dossier:personal-excerpt-update / dossier:intel-excerpt-update events.
This commit is contained in:
Jason Fraley 2026-09-06 05:32:23 -04:00
parent c37a848d66
commit 60da363a93
11 changed files with 23075 additions and 40 deletions

View file

@ -13,6 +13,13 @@ import {
getLeaderAggregate, getLeaderAggregate,
upsertEvaluation, upsertEvaluation,
} from "@/lib/evaluations"; } from "@/lib/evaluations";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
import type { LexicalStateValue } from "@/lib/lexical/text";
import {
INTEL_EXCERPT_MAX_LENGTH,
PERSONAL_EXCERPT_MAX_LENGTH,
} from "@/lib/profile/excerpt-limits";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -150,3 +157,114 @@ export async function getEvaluationSummary(
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
} }
} }
async function findProfileOwner(
payload: Awaited<ReturnType<typeof getPayload>>,
profileId: number,
): Promise<{ ownerId: number } | null> {
const profile = await payload.findByID({
collection: "profiles",
id: profileId,
depth: 0,
select: { user: true },
overrideAccess: true,
});
if (!profile) return null;
const ownerId = typeof profile.user === "object" ? profile.user?.id : profile.user;
if (typeof ownerId !== "number") return null;
return { ownerId };
}
export async function updatePersonalExcerpt(input: {
profileId: number;
content: LexicalStateValue;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const profile = await findProfileOwner(payload, input.profileId);
if (!profile) {
return { success: false, error: "Profile not found." };
}
if (profile.ownerId !== user.id) {
return { success: false, error: "You can only edit your own personal excerpt." };
}
if (lexicalPlaintextLength(input.content) > PERSONAL_EXCERPT_MAX_LENGTH) {
return {
success: false,
error: `Personal excerpt must be ${PERSONAL_EXCERPT_MAX_LENGTH} characters or fewer.`,
};
}
await payload.update({
collection: "profiles",
id: input.profileId,
data: { dossier: { personalExcerpt: input.content } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.DossierPersonalExcerptUpdate,
message: "A personal excerpt was updated.",
targetCollection: "users",
targetId: profile.ownerId,
data: { profileId: input.profileId },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function updateIntelExcerpt(input: {
profileId: number;
content: LexicalStateValue;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const permitted = await hasPermission(payload, user, "profiles:intel_excerpt:update");
if (!permitted) {
return { success: false, error: "You are not allowed to edit intelligence records." };
}
const profile = await findProfileOwner(payload, input.profileId);
if (!profile) {
return { success: false, error: "Profile not found." };
}
if (lexicalPlaintextLength(input.content) > INTEL_EXCERPT_MAX_LENGTH) {
return {
success: false,
error: `Intelligence record must be ${INTEL_EXCERPT_MAX_LENGTH} characters or fewer.`,
};
}
await payload.update({
collection: "profiles",
id: input.profileId,
data: { dossier: { intelExcerpt: input.content } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.DossierIntelExcerptUpdate,
message: "An intelligence record was updated.",
targetCollection: "users",
targetId: profile.ownerId,
data: { profileId: input.profileId },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}

View file

@ -4,11 +4,10 @@ import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types"; import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { ClientDate } from "@/components/frontend/ClientDate"; import { ClientDate } from "@/components/frontend/ClientDate";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser, hasPermission } from "@/utils/access-control/hasPermission";
import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations"; import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations";
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels"; import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
import { getEvaluationSummary } from "./actions"; import { getEvaluationSummary } from "./actions";
@ -20,9 +19,7 @@ import {
CrosshairIcon, CrosshairIcon,
FileTextIcon, FileTextIcon,
HashIcon, HashIcon,
HourglassIcon,
PlaneIcon, PlaneIcon,
ShieldCheck,
ShieldIcon, ShieldIcon,
SkullIcon, SkullIcon,
SparkleIcon, SparkleIcon,
@ -43,6 +40,7 @@ import AwardCard from "@/components/frontend/account/AwardCard";
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton"; import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { RibbonRack } from "@/components/frontend/account/RibbonRack";
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
import type { import type {
EvaluationMissionOption, EvaluationMissionOption,
@ -206,12 +204,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
viewer && !isProfileOwner viewer && !isProfileOwner
? isDirectLeaderOf(payload, viewer.id, user.id) ? isDirectLeaderOf(payload, viewer.id, user.id)
: Promise.resolve(false); : Promise.resolve(false);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader] = const canEditIntelPromise: Promise<boolean> = viewer
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
: Promise.resolve(false);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader, canEditIntel] =
await Promise.all([ await Promise.all([
evaluationSummaryPromise, evaluationSummaryPromise,
eligibleMissionsPromise, eligibleMissionsPromise,
privilegedViewerPromise, privilegedViewerPromise,
directLeaderPromise, directLeaderPromise,
canEditIntelPromise,
]); ]);
const canViewSubordinate = const canViewSubordinate =
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader; isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
@ -449,38 +451,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
Personnel Dossier Personnel Dossier
</h2> </h2>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <DossierExcerptEditors
<Item variant="outline"> profileId={profile.id}
<ItemHeader> personalExcerpt={profile.dossier?.personalExcerpt ?? null}
<ItemTitle>Personal Excerpt</ItemTitle> intelExcerpt={profile.dossier?.intelExcerpt ?? null}
</ItemHeader> canEditPersonal={isProfileOwner}
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic"> canEditIntel={canEditIntel}
<p>{profile.dossier?.personalExcerpt || "No personal excerpt on file."}</p> />
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle>Intelligence Record</ItemTitle>
{profile.dossier?.intelExcerpt ? (
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
) : (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
)}
</ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
{profile.dossier?.intelExcerpt ? (
<RichText data={profile.dossier.intelExcerpt} />
) : (
<p>No intelligence record on file.</p>
)}
</ItemContent>
</Item>
</div>
</section> </section>
{/* ── Qualifications ─────────────────────────────────── */} {/* ── Qualifications ─────────────────────────────────── */}

View file

@ -3,6 +3,7 @@ import { Award } from "@/payload-types";
import { broadcastToUser } from "@/lib/realtime/bus"; import { broadcastToUser } from "@/lib/realtime/bus";
import { notifyAwardGrants } from "@/lib/awards"; import { notifyAwardGrants } from "@/lib/awards";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
export const Profiles: CollectionConfig = { export const Profiles: CollectionConfig = {
slug: "profiles", slug: "profiles",
@ -73,7 +74,14 @@ export const Profiles: CollectionConfig = {
}, },
{ {
name: "personalExcerpt", name: "personalExcerpt",
type: "textarea", label: "Personal Excerpt",
type: "richText",
validate: (value: unknown) => {
if (lexicalPlaintextLength(value) > 1000) {
return "Personal excerpt must be 1000 characters or fewer.";
}
return true;
},
}, },
{ {
name: "intelExcerpt", name: "intelExcerpt",

View file

@ -0,0 +1,286 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { HourglassIcon, PencilIcon, Save, ShieldCheck, X } from "lucide-react";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { LexicalComposer } from "@payloadcms/richtext-lexical/lexical/react/LexicalComposer";
import { ContentEditable } from "@payloadcms/richtext-lexical/lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@payloadcms/richtext-lexical/lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalHistoryPlugin";
import { MarkdownShortcutPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalMarkdownShortcutPlugin";
import { OnChangePlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalOnChangePlugin";
import { RichTextPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@payloadcms/richtext-lexical/lexical/rich-text";
import { ListItemNode, ListNode } from "@payloadcms/richtext-lexical/lexical/list";
import { AutoLinkNode, LinkNode } from "@payloadcms/richtext-lexical/lexical/link";
import { $getRoot } from "@payloadcms/richtext-lexical/lexical";
import type { LexicalEditor } from "@payloadcms/richtext-lexical/lexical";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
import type { LexicalStateValue } from "@/lib/lexical/text";
import {
INTEL_EXCERPT_MAX_LENGTH,
PERSONAL_EXCERPT_MAX_LENGTH,
} from "@/lib/profile/excerpt-limits";
import {
updateIntelExcerpt,
updatePersonalExcerpt,
} from "@/app/(frontend)/profile/[username]/actions";
import { cn } from "@/lib/utils";
const EDITOR_NODES = [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
LinkNode,
AutoLinkNode,
];
const EDITOR_CONTENT_CLASSES =
"prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic";
interface ExcerptEditorProps {
value: LexicalStateValue | null;
maxLength: number;
placeholder: string;
onEditChange: (draft: LexicalStateValue | null, length: number) => void;
}
function ExcerptEditor({ value, maxLength, placeholder, onEditChange }: ExcerptEditorProps) {
const [length, setLength] = useState<number>(() => lexicalPlaintextLength(value));
const handleChange = useCallback(
(editorState: ReturnType<LexicalEditor["getEditorState"]>) => {
const serialized: LexicalStateValue = JSON.parse(JSON.stringify(editorState.toJSON()));
setLength(lexicalPlaintextLength(serialized));
onEditChange(serialized, lexicalPlaintextLength(serialized));
},
[onEditChange],
);
return (
<LexicalComposer
initialConfig={{
namespace: `excerpt-editor-${maxLength}`,
nodes: EDITOR_NODES,
editable: true,
editorState: value ? JSON.stringify(value) : undefined,
onError: (error: Error) => {
console.error(error);
},
theme: {
heading: {
h2: "text-lg font-semibold text-foreground mb-2",
h3: "text-base font-semibold text-foreground mb-2",
},
quote: "border-l-2 border-border pl-3 italic text-muted-foreground mb-2",
list: { ul: "list-disc ml-4 mb-2", ol: "list-decimal ml-4 mb-2" },
link: "text-primary underline underline-offset-2",
},
}}
>
<OnChangePlugin onChange={handleChange} ignoreSelectionChange />
<HistoryPlugin />
<MarkdownShortcutPlugin />
<RichTextPlugin
contentEditable={
<div className="relative">
<ContentEditable
className={cn(
"min-h-[140px] w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm text-foreground leading-relaxed outline-none focus-visible:ring-1 focus-visible:ring-ring",
EDITOR_CONTENT_CLASSES,
)}
/>
<p className="absolute bottom-2 right-3 pointer-events-none text-xs text-muted-foreground">
<span className={cn(length > maxLength && "text-red-500 font-medium")}>
{length}
</span>
/{maxLength}
</p>
</div>
}
placeholder={
<div className="pointer-events-none absolute top-2 left-3 text-sm text-muted-foreground">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
);
}
interface ExcerptCardProps {
profileId: number;
kind: "personal" | "intel";
title: string;
value: LexicalStateValue | null;
maxLength: number;
canEdit: boolean;
emptyMessage: string;
placeholder: string;
hasIntelBadge?: boolean;
}
function ExcerptCard({
profileId,
kind,
title,
value,
maxLength,
canEdit,
emptyMessage,
placeholder,
hasIntelBadge,
}: ExcerptCardProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<LexicalStateValue | null>(null);
const [draftLength, setDraftLength] = useState<number>(() => lexicalPlaintextLength(value));
const [saving, setSaving] = useState(false);
const handleEditChange = useCallback((next: LexicalStateValue | null, length: number) => {
setDraft(next);
setDraftLength(length);
}, []);
const handleEdit = () => {
setDraft(null);
setDraftLength(lexicalPlaintextLength(value));
setEditing(true);
};
const handleCancel = () => {
setDraft(null);
setEditing(false);
};
const handleSave = async () => {
const content = draft ?? value;
if (!content) {
handleCancel();
return;
}
if (draftLength > maxLength) return;
setSaving(true);
const result =
kind === "personal"
? await updatePersonalExcerpt({ profileId, content })
: await updateIntelExcerpt({ profileId, content });
setSaving(false);
if (result.success) {
toast.success(kind === "personal" ? "Personal excerpt saved." : "Intelligence record saved.");
setDraft(null);
setEditing(false);
router.refresh();
} else {
toast.error(result.error ?? "Failed to save.");
}
};
const showBadge = kind === "intel" && hasIntelBadge;
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle>{title}</ItemTitle>
{showBadge ? (
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
) : kind === "intel" ? (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
) : null}
{canEdit && !editing ? (
<Button variant="ghost" size="sm" onClick={handleEdit} className="ml-auto">
<PencilIcon className="size-3.5" />
Edit
</Button>
) : null}
</ItemHeader>
<ItemContent
className={cn(
"flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed",
EDITOR_CONTENT_CLASSES,
)}
>
{editing ? (
<div className="w-full flex flex-col gap-3">
<ExcerptEditor
value={value}
maxLength={maxLength}
placeholder={placeholder}
onEditChange={handleEditChange}
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={saving || draftLength > maxLength}>
<Save className="size-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={saving}>
<X className="size-3.5" />
Cancel
</Button>
</div>
</div>
) : value ? (
<RichText data={value} />
) : (
<p>{emptyMessage}</p>
)}
</ItemContent>
</Item>
);
}
interface DossierExcerptEditorsProps {
profileId: number;
personalExcerpt: LexicalStateValue | null;
intelExcerpt: LexicalStateValue | null;
canEditPersonal: boolean;
canEditIntel: boolean;
}
export function DossierExcerptEditors({
profileId,
personalExcerpt,
intelExcerpt,
canEditPersonal,
canEditIntel,
}: DossierExcerptEditorsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ExcerptCard
profileId={profileId}
kind="personal"
title="Personal Excerpt"
value={personalExcerpt}
maxLength={PERSONAL_EXCERPT_MAX_LENGTH}
canEdit={canEditPersonal}
emptyMessage="No personal excerpt on file."
placeholder="Write a short personal introduction..."
/>
<ExcerptCard
profileId={profileId}
kind="intel"
title="Intelligence Record"
value={intelExcerpt}
maxLength={INTEL_EXCERPT_MAX_LENGTH}
canEdit={canEditIntel}
emptyMessage="No intelligence record on file."
placeholder="Draft the intelligence record..."
hasIntelBadge={Boolean(intelExcerpt)}
/>
</div>
);
}

67
src/lib/lexical/text.ts Normal file
View file

@ -0,0 +1,67 @@
/**
* Pure (client-safe) helpers for reading Lexical richText editor states.
*
* No Payload or DOM imports — usable from server actions, collection field
* validators, and client components alike.
*/
interface LexicalNodeLike {
type?: string;
text?: string;
children?: LexicalNodeLike[];
}
interface LexicalStateLike {
root?: LexicalNodeLike;
}
/**
* Structural mirror of Payload's generated richText field data shape.
* Loose on purpose (type/children as any) so a value flows unchanged between
* payload-types reads, payload.update data, and RichText rendering —
* SerializedEditorState itself lacks the index signature Payload's generated
* types carry, so it cannot be used in these positions directly.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface LexicalStateValue {
root: {
type: any;
children: any[];
direction: "ltr" | "rtl" | null;
format: "" | "left" | "start" | "center" | "right" | "end" | "justify";
indent: number;
version: number;
};
[k: string]: unknown;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/**
* Concatenates the text of every text node in a serialized Lexical editor
* state (no separators, so length checks measure actual typed characters).
* Returns "" for null/undefined/malformed values.
*/
export function lexicalToPlaintext(value: unknown): string {
if (!value || typeof value !== "object") return "";
const root = (value as LexicalStateLike).root;
if (!root) return "";
let out = "";
const walk = (node: LexicalNodeLike): void => {
if (node.type === "text" && typeof node.text === "string") {
out += node.text;
}
if (Array.isArray(node.children)) {
node.children.forEach(walk);
}
};
walk(root);
return out;
}
/**
* Character limit for dossier excerpts, measured on plain text content.
*/
export function lexicalPlaintextLength(value: unknown): number {
return lexicalToPlaintext(value).length;
}

View file

@ -0,0 +1,7 @@
/**
* Shared character limits for dossier excerpts (plaintext content, not raw
* JSON). Imported by the server actions, the collection field validator
* wiring, and the client editors.
*/
export const PERSONAL_EXCERPT_MAX_LENGTH = 1000;
export const INTEL_EXCERPT_MAX_LENGTH = 5000;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,93 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
import { plaintextToLexical } from '../lib/tickets/lexical'
// dossier.personalExcerpt changed from textarea (varchar) to lexical richText
// (jsonb). Existing plain-text values are wrapped into a minimal Lexical
// document using the same plaintextToLexical helper the app uses elsewhere,
// guaranteeing the produced shape matches what the editor/RichText expect.
// The conversion runs row-by-row in TypeScript instead of SQL to keep the
// logic in one place; profile counts are small (per-unit personnel records).
interface LegacyRow extends Record<string, unknown> {
id: number
dossier_personal_excerpt: string | null
}
interface JsonbRow extends Record<string, unknown> {
id: number
dossier_personal_excerpt: string | null
}
/** Flattens a serialized Lexical state back to the legacy textarea string. */
function lexicalToLegacyText(value: string): string {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return value
}
const children =
parsed && typeof parsed === "object"
? ((parsed as { root?: { children?: unknown[] } }).root?.children ?? [])
: []
const paragraphs: string[] = []
for (const para of children) {
if (!para || typeof para !== "object") continue
const inline = (para as { children?: unknown[] }).children ?? []
let text = ""
for (const node of inline) {
if (!node || typeof node !== "object") continue
const type = (node as { type?: string }).type
if (type === "text") text += (node as { text?: string }).text ?? ""
if (type === "linebreak") text += "\n"
}
paragraphs.push(text)
}
return paragraphs.join("\n\n")
}
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(
sql`ALTER TABLE "profiles" ADD COLUMN "dossier_personal_excerpt_new" jsonb;`,
)
const result = await db.execute<LegacyRow>(
sql`SELECT "id", "dossier_personal_excerpt" FROM "profiles" WHERE "dossier_personal_excerpt" IS NOT NULL`,
)
const rows = result.rows ?? []
for (const row of rows) {
const lexical = plaintextToLexical(row.dossier_personal_excerpt ?? "")
await db.execute(
sql`UPDATE "profiles" SET "dossier_personal_excerpt_new" = ${JSON.stringify(lexical)}::jsonb WHERE "id" = ${row.id}`,
)
}
await db.execute(sql`ALTER TABLE "profiles" DROP COLUMN "dossier_personal_excerpt";`)
await db.execute(
sql`ALTER TABLE "profiles" RENAME COLUMN "dossier_personal_excerpt_new" TO "dossier_personal_excerpt";`,
)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(
sql`ALTER TABLE "profiles" ADD COLUMN "dossier_personal_excerpt_old" varchar;`,
)
const result = await db.execute<JsonbRow>(
sql`SELECT "id", "dossier_personal_excerpt"::text AS "dossier_personal_excerpt" FROM "profiles" WHERE "dossier_personal_excerpt" IS NOT NULL`,
)
const rows = result.rows ?? []
for (const row of rows) {
const legacy = lexicalToLegacyText(row.dossier_personal_excerpt ?? "")
await db.execute(
sql`UPDATE "profiles" SET "dossier_personal_excerpt_old" = ${legacy} WHERE "id" = ${row.id}`,
)
}
await db.execute(sql`ALTER TABLE "profiles" DROP COLUMN "dossier_personal_excerpt";`)
await db.execute(
sql`ALTER TABLE "profiles" RENAME COLUMN "dossier_personal_excerpt_old" TO "dossier_personal_excerpt";`,
)
}

View file

@ -29,6 +29,7 @@ import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658'; import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502'; import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field'; import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
export const migrations = [ export const migrations = [
{ {
@ -184,6 +185,11 @@ export const migrations = [
{ {
up: migration_20260906_050900_add_evaluation_reminders_field.up, up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down, down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field' name: '20260906_050900_add_evaluation_reminders_field',
},
{
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext',
}, },
]; ];

View file

@ -1866,7 +1866,21 @@ export interface Profile {
profileTitle?: string | null; profileTitle?: string | null;
dossier: { dossier: {
enlistmentDate: string; enlistmentDate: string;
personalExcerpt?: string | null; personalExcerpt?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
intelExcerpt?: { intelExcerpt?: {
root: { root: {
type: string; type: string;

View file

@ -99,6 +99,10 @@ export const EventTypes = {
AccountDiscordRequest: "account:discord-request", AccountDiscordRequest: "account:discord-request",
AccountPreferencesUpdate: "account:preferences-update", AccountPreferencesUpdate: "account:preferences-update",
// Dossier
DossierPersonalExcerptUpdate: "dossier:personal-excerpt-update",
DossierIntelExcerptUpdate: "dossier:intel-excerpt-update",
// Attendance // Attendance
MissionAttendanceChange: "mission:attendance-change", MissionAttendanceChange: "mission:attendance-change",