feat: add Dashboard navigation as presentation configuration
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 44s

Phase 6D pass 1. The shell's navigation was a flat list of module ids, which
is not a usable experience — and the obvious fix carries an obvious trap.
Letting a module declare its folder, or modelling "Media" as something
Livestream and Video belong to, turns a rendering preference into
architecture: a folder becomes an owner, membership starts to look like a
dependency, and the flat module graph acquires a hierarchy nobody decided on.

So the rule comes first: a Dashboard group is a label on a rendering bucket.
No manifest, no version, no artifact, no API, no lifecycle; nothing depends on
it. Livestream depends on Video because it calls Video's functionality, and
that orders startup. Livestream is shown under Media, and that orders nothing.
No parent module, child module, owning module, or group-derived dependency
appears anywhere in the contracts.

No manifest change and no version 5. `manifest.name` has been required since
version 1 and already is the module's product identity, so the shell renders
"Livestream" instead of `livestream` with nothing added. `suggestedGroup` is
deliberately deferred rather than shipped: it would force a hard
manifest-version cutover for a hint, and its only correct semantics needs
initialization logic that risks a suggestion overwriting an administrator's
decision. Defaulting and ownership are different things.

Two Core tables (migration 0003) hold groups and placements — server-side,
because a church configures its Dashboard rather than one browser;
platform-global, because no organization model exists to scope them to.
`group_id` is `on delete set null`, so deleting a group ungroups its modules
and a dangling reference is impossible. `module_id` carries no foreign key,
because modules are packages rather than rows, so a placement for an
uninstalled module is retained and restores its position on reinstall.

`resolveDashboardNavigation` is a pure function of configuration plus the
mountable set: an unplaced module is ungrouped and visible with no
initialization write, an absent module renders nothing, empty groups are kept,
and ordering is total with the module id as final tie-break.

`GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of
the host-neutral module UI inventory rather than merged into it — each generic
descriptor is embedded untouched, so a standalone host never inherits Dashboard
semantics. Reading needs the permission the inventory already needs; organizing
needs the new platform permission `core.dashboard.navigation.manage` through a
narrow, idempotent, closed-validation mutation contract. No editor UI.

The flatness rule is executable: a test discovers real fixtures with a genuine
dependency, arranges them four ways including both in one folder and the
dependency inverted in presentation, and asserts discovery order, dependency
declarations, and the module set stay byte-identical. The end-to-end test
organizes a real installation through the real API, mounts a module from inside
a group, moves it, and asserts the navigation entry is byte-identical across the
move and still mounts the same artifact.

The shell's client for the generic inventory became dead code and was removed
rather than kept speculatively; the endpoint itself is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 12:44:40 -05:00
co-authored by Claude Opus 5
parent ac5b2b7f6c
commit bd34c9c714
49 changed files with 4866 additions and 213 deletions
+3
View File
@@ -0,0 +1,3 @@
export * from "./navigation.js";
export * from "./schema.js";
export * from "./store.js";
+156
View File
@@ -0,0 +1,156 @@
import type { DashboardConfiguration } from "./store.js";
/**
* One module as the Dashboard needs to present it: its identity, its administrator-visible name,
* and nothing about where it lives on disk.
*
* `name` is the module's own `manifest.name` — its product identity, which every manifest has
* already required since version 1. The Dashboard renders it rather than the module id, and a module
* declaring it is *not* the module declaring its navigation: a name answers "what is this product
* called", while a group answers "where does this church want it", and only the second is
* configuration an administrator owns.
*/
export interface DashboardNavigationModule {
readonly id: string;
readonly name: string;
}
export interface DashboardNavigationGroupView<Module extends DashboardNavigationModule> {
/** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly Module[];
}
export interface DashboardNavigationView<Module extends DashboardNavigationModule> {
readonly groups: readonly DashboardNavigationGroupView<Module>[];
/**
* Modules that belong to no group: those an administrator deliberately placed outside every group,
* and those never configured at all — a newly installed module, most importantly. Both render
* here, which is what guarantees a new module is visible the moment it is discovered, with no
* initialization write, no manual database edit, and no migration.
*/
readonly ungrouped: readonly Module[];
}
/** Placed modules first, in the administrator's order; never-configured modules after them, by id.
* A newcomer therefore appears without interrupting an ordering someone chose, and the module id is
* always the final tie-break, so two modules sharing a `sortOrder` still order identically on every
* request and in every process. */
interface SortKey {
readonly configured: 0 | 1;
readonly order: number;
readonly id: string;
}
function compareSortKeys(left: SortKey, right: SortKey): number {
if (left.configured !== right.configured) {
return left.configured - right.configured;
}
if (left.order !== right.order) {
return left.order - right.order;
}
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
}
/**
* Resolves stored Dashboard configuration against the modules that are actually mountable right now.
*
* Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored
* arrangement and the current module set and returns what to render, so every rule below is
* directly testable without PostgreSQL.
*
* ## The rules, exactly
*
* - **A module with no placement is ungrouped and visible.** Nothing is written to make that true.
* Installing a module is enough to see it; an administrator moves it afterwards if they want to.
* - **A stored placement for a module that is not installed renders nothing, and is retained.** The
* store keeps the row, so uninstalling and reinstalling a module restores its position rather than
* silently resetting it. A dead navigation entry never appears.
* - **A placement naming a group that no longer exists cannot occur** — the schema's
* `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow
* did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a
* configuration inconsistency.
* - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose
* only module is temporarily uninstalled, still renders; hiding it would look like the group had
* been deleted.
* - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding
* order values never produce a nondeterministic result.
*
* ## What this function structurally cannot do
*
* It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it
* returns no edge between two modules. Two modules sharing a group are exactly as related as two
* modules that do not: grouping is a rendering decision computed here, downstream of everything that
* decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime
* startup, authorization — ever calls this.
*/
export function resolveDashboardNavigation<Module extends DashboardNavigationModule>(
configuration: DashboardConfiguration,
mountableModules: readonly Module[],
): DashboardNavigationView<Module> {
const placementsByModuleId = new Map(
configuration.placements.map((placement) => [placement.moduleId, placement] as const),
);
const groupIds = new Set(configuration.groups.map((group) => group.id));
const modulesByGroupId = new Map<string, Array<{ key: SortKey; module: Module }>>();
const ungrouped: Array<{ key: SortKey; module: Module }> = [];
for (const module of mountableModules) {
const placement = placementsByModuleId.get(module.id);
const key: SortKey = {
configured: placement ? 0 : 1,
id: module.id,
order: placement?.sortOrder ?? 0,
};
const groupId =
placement?.groupId !== undefined &&
placement.groupId !== null &&
groupIds.has(placement.groupId)
? placement.groupId
: undefined;
if (groupId === undefined) {
ungrouped.push({ key, module });
continue;
}
const bucket = modulesByGroupId.get(groupId);
if (bucket) {
bucket.push({ key, module });
} else {
modulesByGroupId.set(groupId, [{ key, module }]);
}
}
const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] =>
Object.freeze(
[...entries]
.sort((left, right) => compareSortKeys(left.key, right.key))
.map((entry) => entry.module),
);
const groups = [...configuration.groups]
.sort((left, right) =>
left.sortOrder !== right.sortOrder
? left.sortOrder - right.sortOrder
: left.id < right.id
? -1
: left.id > right.id
? 1
: 0,
)
.map((group) =>
Object.freeze({
id: group.id,
label: group.label,
modules: sortModules(modulesByGroupId.get(group.id) ?? []),
order: group.sortOrder,
}),
);
return Object.freeze({
groups: Object.freeze(groups),
ungrouped: sortModules(ungrouped),
});
}
+95
View File
@@ -0,0 +1,95 @@
import { sql } from "drizzle-orm";
import { check, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned storage for how the Ekklesia Dashboard *presents* installed modules.
*
* This is presentation configuration, and it is deliberately the only thing here. A group owns no
* tables, no migrations, no routes, and no lifecycle; it never participates in dependency
* resolution, discovery order, or module startup. **Modules stay flat** (ADR 0021): there is no
* parent module, no child module, no owning module, and nothing in this schema is ever read by
* anything that decides what a module may do or when it starts. Moving a module between groups
* changes a row here and nothing else.
*
* Core stores this; it renders nothing. `surfaces/shell` decides what the arrangement looks like.
*
* Platform-global, because Ekklesia has no organization or tenant model to scope it to. When one
* exists, these two tables gain a scope column; inventing a second notion of organization here to
* anticipate it would be worse than adding a column later.
*
* Table and index names are explicit and namespaced exactly like `core/src/authorization/schema.ts`,
* so ownership is unambiguous inside the shared public PostgreSQL schema.
*/
/**
* A Dashboard navigation group — "Media", "Planning", "Sunday Production".
*
* `id` is an **opaque, stable key**, never displayed and never re-derived from `label`. Renaming a
* group changes only `label`, so every placement pointing at it survives untouched; that invariant
* is what keeps a rename from silently re-organizing the Dashboard. A slug is used rather than a
* generated UUID purely so the configuration API and its tests stay legible — it carries no meaning
* the platform reads.
*
* There is no parent group column. Nesting is deliberately out of scope, and leaving the shape flat
* is what keeps it out.
*/
export const ekklesiaCoreDashboardGroup = pgTable(
"ekklesia_core_dashboard_group",
{
id: text("id").primaryKey(),
label: text("label").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_dashboard_group_order_idx").on(table.sortOrder, table.id),
// Defense in depth beneath the application-layer identifier and label contracts, which never
// produce either of these: an empty id would be an unaddressable group, and an empty or
// whitespace-only label would render as a blank heading no administrator could find again.
check("ekklesia_core_dashboard_group_id_check", sql`length(${table.id}) > 0`),
check("ekklesia_core_dashboard_group_label_check", sql`length(btrim(${table.label})) > 0`),
],
);
/**
* Where one module sits in the Dashboard. One row per module at most — the primary key enforces it
* — so a module can never be placed in two groups, which is the database-level expression of "one
* level of grouping, and no module ownership graph."
*
* `group_id` is nullable and that is a real state, not a missing value: **ungrouped is a placement**,
* chosen by an administrator who ordered a module outside every group. A module with no row at all
* is a different state — never configured — and both render in the same ungrouped area (see
* `../dashboard/navigation.js` for the exact ordering rule).
*
* `on delete set null` is what makes group deletion safe: deleting a group cannot leave a dangling
* reference, and its modules become ungrouped rather than disappearing. That is enforced by the
* database, not by application cleanup that could be skipped.
*
* `module_id` deliberately carries **no foreign key to any module**, because modules are not rows —
* they are packages discovered on disk at startup. A placement for a module that is not installed
* is therefore normal and is retained: uninstalling and reinstalling a module keeps its position
* rather than silently resetting it. Nothing renders such a row (see `resolveDashboardNavigation`).
*/
export const ekklesiaCoreDashboardPlacement = pgTable(
"ekklesia_core_dashboard_placement",
{
moduleId: text("module_id").primaryKey(),
groupId: text("group_id").references(() => ekklesiaCoreDashboardGroup.id, {
onDelete: "set null",
}),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_dashboard_placement_group_idx").on(table.groupId, table.sortOrder),
check("ekklesia_core_dashboard_placement_module_check", sql`length(${table.moduleId}) > 0`),
],
);
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`'s explicit-mapping style. */
export const dashboardSchema = {
dashboardGroup: ekklesiaCoreDashboardGroup,
dashboardPlacement: ekklesiaCoreDashboardPlacement,
} as const;
+314
View File
@@ -0,0 +1,314 @@
import { asc, eq } from "drizzle-orm";
import type { Pool } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import {
dashboardSchema,
ekklesiaCoreDashboardGroup,
ekklesiaCoreDashboardPlacement,
} from "./schema.js";
export type DashboardDrizzleDatabase = NodePgDatabase<typeof dashboardSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`:
* it never constructs, registers an error listener on, or closes a pool. The caller owns the pool
* once, however many typed views it creates over it.
*/
export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase {
return drizzle(pool, { schema: dashboardSchema });
}
/** Bounds every stored ordering value. Wide enough that an administrator never runs out of room
* between two entries, small enough that a client cannot store a value that surprises a later
* integer column. Enforced identically by the store and by the HTTP surface, so there is one bound
* rather than two that could drift. */
export const DASHBOARD_SORT_ORDER_MIN = 0;
export const DASHBOARD_SORT_ORDER_MAX = 1_000_000;
/** The label an administrator sees. Bounded like every other operator-facing string in the
* platform; `moduleManifestSchema.name` uses the same ceiling. */
export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120;
/** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier
* shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a
* label. */
const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const GROUP_ID_MAX_LENGTH = 64;
/** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP
* boundary rejects malformed input with a request error *before* reaching the store rather than
* relying on mapping a store failure back to a status code — and so there is one grammar rather
* than two that could drift. */
export function isValidDashboardIdentifier(value: unknown): value is string {
return (
typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value)
);
}
/** The one label rule, exported for the same reason. Returns the trimmed value the store would
* persist, or `undefined` when the label is empty, whitespace-only, or too long. */
export function normalizeDashboardGroupLabel(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH
? undefined
: trimmed;
}
export type DashboardStoreErrorCode =
| "DASHBOARD_GROUP_ID_INVALID"
| "DASHBOARD_GROUP_LABEL_INVALID"
| "DASHBOARD_GROUP_NOT_FOUND"
| "DASHBOARD_MODULE_ID_INVALID"
| "DASHBOARD_SORT_ORDER_INVALID"
| "DASHBOARD_READ_FAILED"
| "DASHBOARD_WRITE_FAILED";
const storeErrorMessages: Readonly<Record<DashboardStoreErrorCode, string>> = Object.freeze({
DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.",
DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.",
DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.",
DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.",
DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.",
DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.",
DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, or stack — matching
* `AuthorizationStoreError` exactly. */
export class DashboardStoreError extends Error {
readonly code: DashboardStoreErrorCode;
constructor(code: DashboardStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "DashboardStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** A stored group. `label` is what an administrator sees; `id` is the stable key placements point
* at, and a rename never touches it. */
export interface DashboardGroupRecord {
readonly id: string;
readonly label: string;
readonly sortOrder: number;
}
/** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is
* distinct from having no record at all, which means never configured. */
export interface DashboardPlacementRecord {
readonly moduleId: string;
readonly groupId: string | null;
readonly sortOrder: number;
}
/** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the
* modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */
export interface DashboardConfiguration {
readonly groups: readonly DashboardGroupRecord[];
readonly placements: readonly DashboardPlacementRecord[];
}
export interface UpsertDashboardGroupInput {
readonly id: string;
readonly label: string;
readonly sortOrder: number;
}
export interface SetDashboardPlacementInput {
readonly moduleId: string;
/** `null` places the module in the ungrouped area deliberately. A group id that does not exist is
* rejected rather than stored, so a placement can never point at a group that was never created. */
readonly groupId: string | null;
readonly sortOrder: number;
}
export interface DashboardStore {
/** The whole stored arrangement, in deterministic order. Small by nature — one row per group and
* at most one per module — so it is read whole rather than paginated. */
readConfiguration(): Promise<DashboardConfiguration>;
/** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the
* same single row, and placements pointing at it are untouched either way. */
upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord>;
/** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so
* no dangling reference is possible and no module is lost. */
deleteGroup(groupId: string): Promise<void>;
/** Creates or replaces one module's placement. */
setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord>;
}
function assertValidGroupId(value: string): void {
if (!isValidDashboardIdentifier(value)) {
throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID");
}
}
function assertValidModuleId(value: string): void {
if (!isValidDashboardIdentifier(value)) {
throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID");
}
}
function normalizedLabel(value: string): string {
const trimmed = normalizeDashboardGroupLabel(value);
if (trimmed === undefined) {
throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID");
}
return trimmed;
}
function assertValidSortOrder(value: number): void {
if (
!Number.isSafeInteger(value) ||
value < DASHBOARD_SORT_ORDER_MIN ||
value > DASHBOARD_SORT_ORDER_MAX
) {
throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID");
}
}
function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never {
if (error instanceof DashboardStoreError) {
throw error;
}
throw new DashboardStoreError(code);
}
/**
* Core's persistence for Dashboard navigation configuration.
*
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for any
* caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is
* a bounded safe integer, and a placement may only name a group that exists. Nothing here reads,
* imports, or reasons about a module — a placement is a `module_id` string and no more — which is
* what keeps grouping structurally incapable of influencing discovery, dependency resolution, or
* startup order.
*/
export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore {
return Object.freeze({
async readConfiguration(): Promise<DashboardConfiguration> {
try {
const [groups, placements] = await Promise.all([
db
.select({
id: ekklesiaCoreDashboardGroup.id,
label: ekklesiaCoreDashboardGroup.label,
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
})
.from(ekklesiaCoreDashboardGroup)
.orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)),
db
.select({
groupId: ekklesiaCoreDashboardPlacement.groupId,
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
})
.from(ekklesiaCoreDashboardPlacement)
.orderBy(
asc(ekklesiaCoreDashboardPlacement.sortOrder),
asc(ekklesiaCoreDashboardPlacement.moduleId),
),
]);
return Object.freeze({
groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))),
placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))),
});
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_READ_FAILED");
}
},
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
assertValidGroupId(input.id);
const label = normalizedLabel(input.label);
assertValidSortOrder(input.sortOrder);
try {
// A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is
// never rewritten and every placement pointing at this group keeps pointing at it.
const [row] = await db
.insert(ekklesiaCoreDashboardGroup)
.values({ id: input.id, label, sortOrder: input.sortOrder })
.onConflictDoUpdate({
set: { label, sortOrder: input.sortOrder, updatedAt: new Date() },
target: ekklesiaCoreDashboardGroup.id,
})
.returning({
id: ekklesiaCoreDashboardGroup.id,
label: ekklesiaCoreDashboardGroup.label,
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
});
if (!row) {
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
async deleteGroup(groupId: string): Promise<void> {
assertValidGroupId(groupId);
try {
const deleted = await db
.delete(ekklesiaCoreDashboardGroup)
.where(eq(ekklesiaCoreDashboardGroup.id, groupId))
.returning({ id: ekklesiaCoreDashboardGroup.id });
if (deleted.length === 0) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
assertValidModuleId(input.moduleId);
assertValidSortOrder(input.sortOrder);
if (input.groupId !== null) {
assertValidGroupId(input.groupId);
}
try {
// Checked in the application rather than left to the foreign key so the caller gets
// `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the
// authority: a group deleted concurrently still fails the insert rather than storing a
// dangling reference.
if (input.groupId !== null) {
const existing = await db
.select({ id: ekklesiaCoreDashboardGroup.id })
.from(ekklesiaCoreDashboardGroup)
.where(eq(ekklesiaCoreDashboardGroup.id, input.groupId))
.limit(1);
if (existing.length === 0) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
}
const [row] = await db
.insert(ekklesiaCoreDashboardPlacement)
.values({
groupId: input.groupId,
moduleId: input.moduleId,
sortOrder: input.sortOrder,
})
.onConflictDoUpdate({
set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() },
target: ekklesiaCoreDashboardPlacement.moduleId,
})
.returning({
groupId: ekklesiaCoreDashboardPlacement.groupId,
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
});
if (!row) {
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
});
}