fc8eaa86c3
- Fix PATCH /api/projects/:id/shares/:userId: query SELECT 1 instead of SELECT id (project_shares has a composite PK, no id column) - Add audit logging to existing share grant/revoke routes - Add POST /api/projects/:id/transfer for ownership handoff - Remove inline admin user/audit routes from routes.ts (now in admin.ts) - Remove unused requireAdmin import and userPatch schema from routes.ts
355 lines
7.9 KiB
TypeScript
355 lines
7.9 KiB
TypeScript
import type {
|
|
AISession,
|
|
AISessionRole,
|
|
AuditLogEntry,
|
|
Board,
|
|
Book,
|
|
Chapter,
|
|
Character,
|
|
Entity,
|
|
Note,
|
|
PlotBeat,
|
|
Project,
|
|
ProjectMeta,
|
|
User,
|
|
WorldItem,
|
|
} from "@pubscript/core";
|
|
|
|
// ---- Row shapes (mirror the SQLite schema) ----
|
|
export interface UserRow {
|
|
id: string;
|
|
email: string;
|
|
pass_hash: string;
|
|
display_name: string;
|
|
role: "admin" | "author";
|
|
disabled: number;
|
|
created_at: string;
|
|
}
|
|
export interface ShareRow {
|
|
project_id: string;
|
|
user_id: string;
|
|
perm: "read" | "edit";
|
|
}
|
|
|
|
/** Raw `audit` table row (CHUNK #10). */
|
|
export interface AuditRow {
|
|
id: string;
|
|
at: string;
|
|
user_id: string | null;
|
|
action: string;
|
|
detail: string;
|
|
}
|
|
export interface ProjectRow {
|
|
id: string;
|
|
owner_id: string;
|
|
title: string;
|
|
settings: string;
|
|
cover: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export interface BookRow {
|
|
id: string;
|
|
project_id: string;
|
|
title: string;
|
|
ord: number;
|
|
}
|
|
export interface ChapterRow {
|
|
id: string;
|
|
book_id: string;
|
|
project_id: string;
|
|
title: string;
|
|
doc: string;
|
|
rev: number;
|
|
ord: number;
|
|
updated_at: string;
|
|
}
|
|
export interface EntityRow {
|
|
id: string;
|
|
project_id: string;
|
|
kind: string;
|
|
name: string;
|
|
fields: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export interface BoardRow {
|
|
id: string;
|
|
project_id: string;
|
|
nodes: string;
|
|
edges: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface PlotBeatRow {
|
|
id: string;
|
|
project_id: string;
|
|
title: string;
|
|
body: string;
|
|
act: number;
|
|
type: string;
|
|
beat_order: number;
|
|
status: string;
|
|
chapter_id: string | null;
|
|
entity_ids: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export interface AISessionRow {
|
|
id: string;
|
|
project_id: string;
|
|
role: string;
|
|
model: string;
|
|
settings: string;
|
|
messages: string;
|
|
suggestions: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
// ---- Row -> core types ----
|
|
export function toUser(r: UserRow): User {
|
|
return {
|
|
id: r.id,
|
|
email: r.email,
|
|
displayName: r.display_name,
|
|
role: r.role,
|
|
createdAt: r.created_at,
|
|
};
|
|
}
|
|
|
|
/** Map a raw `audit` row to the shared `AuditLogEntry` (CHUNK #10). */
|
|
export function toAudit(r: AuditRow): AuditLogEntry {
|
|
let detail: Record<string, unknown> = {};
|
|
try {
|
|
detail = JSON.parse(r.detail ?? "{}") as Record<string, unknown>;
|
|
} catch {
|
|
detail = {};
|
|
}
|
|
return {
|
|
id: r.id,
|
|
at: r.at,
|
|
userId: r.user_id,
|
|
action: r.action,
|
|
detail,
|
|
};
|
|
}
|
|
|
|
export function toProject(r: ProjectRow, shares: ShareRow[]): Project {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.id,
|
|
title: r.title,
|
|
ownerId: r.owner_id,
|
|
sharedWith: shares.map((s) => ({ userId: s.user_id, perm: s.perm })),
|
|
settings: JSON.parse(r.settings),
|
|
cover: JSON.parse(r.cover),
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at,
|
|
};
|
|
}
|
|
|
|
export function toProjectMeta(r: ProjectRow, shares: ShareRow[], user: User): ProjectMeta {
|
|
const owned = r.owner_id === user.id;
|
|
const mine = shares.find((s) => s.user_id === user.id);
|
|
return {
|
|
id: r.id,
|
|
projectId: r.id,
|
|
title: r.title,
|
|
ownerId: r.owner_id,
|
|
cover: JSON.parse(r.cover),
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at,
|
|
perm: owned ? "edit" : (mine?.perm ?? "read"),
|
|
owned,
|
|
};
|
|
}
|
|
|
|
export function toBook(r: BookRow, chapters: ChapterRow[]): Book {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
title: r.title,
|
|
order: r.ord,
|
|
chapters: chapters
|
|
.slice()
|
|
.sort((a, b) => a.ord - b.ord)
|
|
.map(toChapter),
|
|
};
|
|
}
|
|
|
|
export function toChapter(r: ChapterRow): Chapter {
|
|
return {
|
|
id: r.id,
|
|
bookId: r.book_id,
|
|
projectId: r.project_id,
|
|
title: r.title,
|
|
doc: JSON.parse(r.doc),
|
|
rev: r.rev,
|
|
order: r.ord,
|
|
meta: {
|
|
wordCount: wordCount(JSON.parse(r.doc)),
|
|
scenes: [],
|
|
updatedAt: r.updated_at,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function toBoard(r: BoardRow): Board {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
nodes: JSON.parse(r.nodes),
|
|
edges: JSON.parse(r.edges),
|
|
};
|
|
}
|
|
|
|
export function toPlotBeat(r: PlotBeatRow): PlotBeat {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
title: r.title,
|
|
body: r.body,
|
|
act: r.act,
|
|
type: r.type as PlotBeat["type"],
|
|
order: r.beat_order,
|
|
status: (r.status as PlotBeat["status"]) ?? "draft",
|
|
chapterId: r.chapter_id ?? undefined,
|
|
entityIds: parseEntityIds(r.entity_ids),
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at,
|
|
};
|
|
}
|
|
|
|
/** Parse the JSON `entity_ids` column into a string[] (empty when blank). */
|
|
function parseEntityIds(raw: string): string[] {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function toAISession(r: AISessionRow): AISession {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
role: r.role as AISessionRole,
|
|
model: r.model,
|
|
settings: JSON.parse(r.settings),
|
|
messages: JSON.parse(r.messages),
|
|
suggestions: JSON.parse(r.suggestions),
|
|
};
|
|
}
|
|
|
|
export function toEntity(r: EntityRow): Entity {
|
|
const f = JSON.parse(r.fields);
|
|
if (r.kind === "character") {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
name: r.name,
|
|
role: f.role ?? "",
|
|
bio: f.bio ?? "",
|
|
appearance: f.appearance ?? "",
|
|
voice: f.voice ?? "",
|
|
relationships: typeof f.relationships === "string" ? JSON.parse(f.relationships) : (f.relationships ?? []),
|
|
arc: f.arc ?? "",
|
|
status: f.status ?? "active",
|
|
tags: f.tags ?? [],
|
|
} satisfies Character;
|
|
}
|
|
if (r.kind === "world") {
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
kind: f.kind ?? "location",
|
|
name: r.name,
|
|
fields: f.fields ?? {},
|
|
} satisfies WorldItem;
|
|
}
|
|
return {
|
|
id: r.id,
|
|
projectId: r.project_id,
|
|
body: f.body ?? "",
|
|
pinned: !!f.pinned,
|
|
tags: f.tags ?? [],
|
|
linkedTo: f.linkedTo ?? [],
|
|
} satisfies Note;
|
|
}
|
|
|
|
// ---- core types -> row (for writes) ----
|
|
export function entityToRow(e: Entity): Omit<EntityRow, "created_at" | "updated_at"> {
|
|
if ("fields" in e) {
|
|
// WorldItem
|
|
return {
|
|
id: e.id,
|
|
project_id: e.projectId,
|
|
kind: "world",
|
|
name: e.name,
|
|
fields: JSON.stringify({ kind: e.kind, fields: e.fields }),
|
|
};
|
|
}
|
|
if ("body" in e) {
|
|
// Note
|
|
return {
|
|
id: e.id,
|
|
project_id: e.projectId,
|
|
kind: "note",
|
|
name: e.body.slice(0, 80),
|
|
fields: JSON.stringify({ body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo }),
|
|
};
|
|
}
|
|
// Character
|
|
const c = e as Character;
|
|
return {
|
|
id: c.id,
|
|
project_id: c.projectId,
|
|
kind: "character",
|
|
name: c.name,
|
|
fields: JSON.stringify({
|
|
role: c.role,
|
|
bio: c.bio,
|
|
appearance: c.appearance,
|
|
voice: c.voice,
|
|
relationships: c.relationships,
|
|
arc: c.arc,
|
|
status: c.status,
|
|
tags: c.tags,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// ---- helpers ----
|
|
/** Concatenate every human-readable field of an entity into one searchable string. */
|
|
export function entitySearchText(e: Entity): string {
|
|
if ("fields" in e) {
|
|
const parts = [e.name, e.kind];
|
|
for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
|
|
return parts.join(" ");
|
|
}
|
|
if ("body" in e) {
|
|
return [e.body, ...e.tags].join(" ");
|
|
}
|
|
const c = e as Character;
|
|
const relText = (c.relationships ?? []).map((r) => `${r.targetId} ${r.kind} ${r.note}`).join(" ");
|
|
return [c.name, c.role, c.bio, c.appearance, c.voice, relText, c.arc, c.status, ...c.tags].join(" ");
|
|
}
|
|
|
|
/** Count words in a ProseMirror doc (text nodes only). */
|
|
export function wordCount(doc: unknown): number {
|
|
let count = 0;
|
|
const walk = (node: any): void => {
|
|
if (node && typeof node === "object") {
|
|
if (node.type === "text") {
|
|
const t = String(node.text ?? "");
|
|
count += t.split(/\s+/).filter(Boolean).length;
|
|
}
|
|
if (Array.isArray(node.content)) for (const c of node.content) walk(c);
|
|
}
|
|
};
|
|
walk(doc);
|
|
return count;
|
|
}
|