1
0
Fork 0

feat(tech-tree): add tech-categories collection, permissions, and treePosition

Add the tech-categories collection that gives technologies a home off the
flat uncategorized pool, plus the schema support the admin editor needs.

- New `tech-categories` collection (unique name, timeline `position`,
  optional hex `color`) with a `Technologies.category` FK (ON DELETE set
  null) so deleting a category just unfiles its technologies.
- Add `Technologies.treePosition {x,y}` so the canvas can pin a node's
  manual position; clearing it uses `{x:null,y:null}`.
- Register the two hand-written migrations (tech-categories + treePosition)
  and regenerate payload-types, the drizzle schema, and the admin importMap.
- Register the `tech-categories:*` permission group and grant Intelligence
  division members admin-panel access, mirroring technologies.
This commit is contained in:
Jason Fraley 2026-10-01 10:03:49 -04:00
parent 954e6e2c44
commit 15f2d0c573
12 changed files with 70877 additions and 3 deletions

View file

@ -36,12 +36,14 @@ import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/component
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
import { default as default_a6bf792526e9f56202161f190c57e1c6 } from '@/components/admin/tech-tree/TechTreeNav'
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
import { default as default_05548cb43495e62d80b80ec4d9f0f7e9 } from '@/components/static/BeforeDashboardText'
import { default as default_459d6b6f5ead73c736013da63a5ace3f } from '@/components/static/GoToFrontendLink'
import { InstructionsProvider as InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { default as default_2b34d0df0cf91f4442ca2cd62ae7cd94 } from '@/components/admin/tech-tree/TechTreeView'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
@ -84,11 +86,13 @@ export const importMap = {
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
"@/components/admin/tech-tree/TechTreeNav#default": default_a6bf792526e9f56202161f190c57e1c6,
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,
"@/components/static/BeforeDashboardText#default": default_05548cb43495e62d80b80ec4d9f0f7e9,
"@/components/static/GoToFrontendLink#default": default_459d6b6f5ead73c736013da63a5ace3f,
"@ai-stack/payloadcms/client#InstructionsProvider": InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73,
"@/components/admin/tech-tree/TechTreeView#default": default_2b34d0df0cf91f4442ca2cd62ae7cd94,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View file

@ -0,0 +1,58 @@
import { CollectionConfig } from "payload";
import { requireIntelligencePermission } from "@/utils/access-control/divisionAccess";
/** Validates an optional hex color like the map feature styling (`src/lib/map/style.ts`). */
function validateHexColor(value: unknown): true | string {
if (!value) return true;
const str = String(value).trim();
if (!str) return true;
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(str)
? true
: "Enter a hex color like #22d3ee, or leave empty for the default.";
}
export const TechCategories: CollectionConfig = {
slug: "tech-categories",
admin: {
group: "Intelligence",
useAsTitle: "name",
},
access: {
create: requireIntelligencePermission("tech-categories:create"),
update: requireIntelligencePermission("tech-categories:update"),
delete: requireIntelligencePermission("tech-categories:delete"),
read: requireIntelligencePermission("tech-categories:read"),
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
admin: {
description:
"Category label shown as a column band on the Tech Tree (e.g. Tier 1, Tier 2).",
},
},
{
name: "position",
type: "number",
required: true,
min: 1,
defaultValue: 1,
admin: {
description:
"Timeline position: lower numbers sit further left on the Tech Tree. Categories with the same position are ordered alphabetically.",
},
},
{
name: "color",
type: "text",
validate: validateHexColor,
admin: {
description: "Optional hex accent color for the band on the Tech Tree (e.g. #22d3ee).",
},
},
],
};

View file

@ -26,6 +26,34 @@ export const Technologies: CollectionConfig = {
type: "text",
required: true,
},
{
name: "category",
type: "relationship",
relationTo: "tech-categories",
admin: {
description:
"Tech Tree column this technology belongs to. Uncategorized technologies are auto-placed on the Tech Tree by prerequisite depth.",
},
},
{
name: "treePosition",
type: "group",
label: "Tech Tree Position",
admin: {
description:
"Manual position on the Tech Tree canvas (set by dragging a node). Leave empty to use the automatic layout.",
},
fields: [
{
name: "x",
type: "number",
},
{
name: "y",
type: "number",
},
],
},
{
name: "approvalStatus",
type: "select",

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "technologies" ADD COLUMN "tree_position_x" numeric;
ALTER TABLE "technologies" ADD COLUMN "tree_position_y" numeric;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "technologies" DROP COLUMN "tree_position_x";
ALTER TABLE "technologies" DROP COLUMN "tree_position_y";`)
}

View file

@ -95,6 +95,8 @@ import * as migration_20260926_030859_add_mission_role_description from './20260
import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext';
import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model';
import * as migration_20260930_162200_add_documentation_wiki_category from './20260930_162200_add_documentation_wiki_category';
import * as migration_20260930_230747_add_tech_categories from './20260930_230747_add_tech_categories';
import * as migration_20261001_000320_add_tech_tree_position from './20261001_000320_add_tech_tree_position';
export const migrations = [
{
@ -575,11 +577,21 @@ export const migrations = [
{
up: migration_20260928_021138_change_ai_model.up,
down: migration_20260928_021138_change_ai_model.down,
name: '20260928_021138_change_ai_model'
name: '20260928_021138_change_ai_model',
},
{
up: migration_20260930_162200_add_documentation_wiki_category.up,
down: migration_20260930_162200_add_documentation_wiki_category.down,
name: '20260930_162200_add_documentation_wiki_category'
name: '20260930_162200_add_documentation_wiki_category',
},
{
up: migration_20260930_230747_add_tech_categories.up,
down: migration_20260930_230747_add_tech_categories.down,
name: '20260930_230747_add_tech_categories',
},
{
up: migration_20261001_000320_add_tech_tree_position.up,
down: migration_20261001_000320_add_tech_tree_position.down,
name: '20261001_000320_add_tech_tree_position'
},
];

View file

@ -283,6 +283,11 @@ export const enum_roles_permissions = pgEnum("enum_roles_permissions", [
"technologies:update",
"technologies:delete",
"admin:technologies:manage",
"tech-categories:create",
"tech-categories:read",
"tech-categories:update",
"tech-categories:delete",
"admin:tech-categories:manage",
"assets:create",
"assets:read",
"assets:update",
@ -3317,6 +3322,11 @@ export const technologies = pgTable(
{
id: serial("id").primaryKey(),
name: varchar("name").notNull(),
category: integer("category_id").references(() => tech_categories.id, {
onDelete: "set null",
}),
treePosition_x: numeric("tree_position_x", { mode: "number" }),
treePosition_y: numeric("tree_position_y", { mode: "number" }),
approvalStatus: enum_technologies_approval_status("approval_status")
.notNull()
.default("in_progress"),
@ -3339,6 +3349,7 @@ export const technologies = pgTable(
.notNull(),
},
(columns) => [
index("technologies_category_idx").on(columns.category),
index("technologies_updated_at_idx").on(columns.updatedAt),
index("technologies_created_at_idx").on(columns.createdAt),
],
@ -3371,6 +3382,27 @@ export const technologies_rels = pgTable(
],
);
export const tech_categories = pgTable(
"tech_categories",
{
id: serial("id").primaryKey(),
name: varchar("name").notNull(),
position: numeric("position", { mode: "number" }).notNull().default(1),
color: varchar("color"),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
},
(columns) => [
uniqueIndex("tech_categories_name_idx").on(columns.name),
index("tech_categories_updated_at_idx").on(columns.updatedAt),
index("tech_categories_created_at_idx").on(columns.createdAt),
],
);
export const assets_skin_configuration_applies_to_asset_types = pgTable(
"assets_skin_configuration_applies_to_asset_types",
{
@ -6856,6 +6888,10 @@ export const payload_mcp_api_keys = pgTable(
technologies_create: boolean("technologies_create").default(false),
technologies_update: boolean("technologies_update").default(false),
technologies_delete: boolean("technologies_delete").default(false),
techCategories_find: boolean("tech_categories_find").default(false),
techCategories_create: boolean("tech_categories_create").default(false),
techCategories_update: boolean("tech_categories_update").default(false),
techCategories_delete: boolean("tech_categories_delete").default(false),
assets_find: boolean("assets_find").default(false),
assets_create: boolean("assets_create").default(false),
assets_update: boolean("assets_update").default(false),
@ -7128,6 +7164,7 @@ export const payload_locked_documents_rels = pgTable(
campaignsID: integer("campaigns_id"),
factionsID: integer("factions_id"),
technologiesID: integer("technologies_id"),
"tech-categoriesID": integer("tech_categories_id"),
assetsID: integer("assets_id"),
resourcesID: integer("resources_id"),
vehiclesID: integer("vehicles_id"),
@ -7226,6 +7263,7 @@ export const payload_locked_documents_rels = pgTable(
index("payload_locked_documents_rels_campaigns_id_idx").on(columns.campaignsID),
index("payload_locked_documents_rels_factions_id_idx").on(columns.factionsID),
index("payload_locked_documents_rels_technologies_id_idx").on(columns.technologiesID),
index("payload_locked_documents_rels_tech_categories_id_idx").on(columns["tech-categoriesID"]),
index("payload_locked_documents_rels_assets_id_idx").on(columns.assetsID),
index("payload_locked_documents_rels_resources_id_idx").on(columns.resourcesID),
index("payload_locked_documents_rels_vehicles_id_idx").on(columns.vehiclesID),
@ -7454,6 +7492,11 @@ export const payload_locked_documents_rels = pgTable(
foreignColumns: [technologies.id],
name: "payload_locked_documents_rels_technologies_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["tech-categoriesID"]],
foreignColumns: [tech_categories.id],
name: "payload_locked_documents_rels_tech_categories_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["assetsID"]],
foreignColumns: [assets.id],
@ -8870,7 +8913,12 @@ export const relations_technologies_rels = relations(technologies_rels, ({ one }
relationName: "technologies",
}),
}));
export const relations_technologies = relations(technologies, ({ many }) => ({
export const relations_technologies = relations(technologies, ({ one, many }) => ({
category: one(tech_categories, {
fields: [technologies.category],
references: [tech_categories.id],
relationName: "category",
}),
requirements_prerequisiteTechnologies: many(technologies_requirements_prerequisite_technologies, {
relationName: "requirements_prerequisiteTechnologies",
}),
@ -8881,6 +8929,7 @@ export const relations_technologies = relations(technologies, ({ many }) => ({
relationName: "_rels",
}),
}));
export const relations_tech_categories = relations(tech_categories, () => ({}));
export const relations_assets_skin_configuration_applies_to_asset_types = relations(
assets_skin_configuration_applies_to_asset_types,
({ one }) => ({
@ -10516,6 +10565,11 @@ export const relations_payload_locked_documents_rels = relations(
references: [technologies.id],
relationName: "technologies",
}),
"tech-categoriesID": one(tech_categories, {
fields: [payload_locked_documents_rels["tech-categoriesID"]],
references: [tech_categories.id],
relationName: "tech-categories",
}),
assetsID: one(assets, {
fields: [payload_locked_documents_rels.assetsID],
references: [assets.id],
@ -11075,6 +11129,7 @@ type DatabaseSchema = {
technologies_research_costs_resources: typeof technologies_research_costs_resources;
technologies: typeof technologies;
technologies_rels: typeof technologies_rels;
tech_categories: typeof tech_categories;
assets_skin_configuration_applies_to_asset_types: typeof assets_skin_configuration_applies_to_asset_types;
assets_crafting_crafting_data_inputs: typeof assets_crafting_crafting_data_inputs;
assets_crafting_crafting_data_byproducts: typeof assets_crafting_crafting_data_byproducts;
@ -11264,6 +11319,7 @@ type DatabaseSchema = {
relations_technologies_research_costs_resources: typeof relations_technologies_research_costs_resources;
relations_technologies_rels: typeof relations_technologies_rels;
relations_technologies: typeof relations_technologies;
relations_tech_categories: typeof relations_tech_categories;
relations_assets_skin_configuration_applies_to_asset_types: typeof relations_assets_skin_configuration_applies_to_asset_types;
relations_assets_crafting_crafting_data_inputs: typeof relations_assets_crafting_crafting_data_inputs;
relations_assets_crafting_crafting_data_byproducts: typeof relations_assets_crafting_crafting_data_byproducts;

View file

@ -95,6 +95,7 @@ export interface Config {
campaigns: Campaign;
factions: Faction;
technologies: Technology;
'tech-categories': TechCategory;
assets: Asset;
resources: Resource;
vehicles: Vehicle;
@ -188,6 +189,7 @@ export interface Config {
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
factions: FactionsSelect<false> | FactionsSelect<true>;
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
'tech-categories': TechCategoriesSelect<false> | TechCategoriesSelect<true>;
assets: AssetsSelect<false> | AssetsSelect<true>;
resources: ResourcesSelect<false> | ResourcesSelect<true>;
vehicles: VehiclesSelect<false> | VehiclesSelect<true>;
@ -656,6 +658,11 @@ export interface Role {
| 'technologies:update'
| 'technologies:delete'
| 'admin:technologies:manage'
| 'tech-categories:create'
| 'tech-categories:read'
| 'tech-categories:update'
| 'tech-categories:delete'
| 'admin:tech-categories:manage'
| 'assets:create'
| 'assets:read'
| 'assets:update'
@ -1538,6 +1545,17 @@ export interface Resource {
export interface Technology {
id: number;
name: string;
/**
* Tech Tree column this technology belongs to. Uncategorized technologies are auto-placed on the Tech Tree by prerequisite depth.
*/
category?: (number | null) | TechCategory;
/**
* Manual position on the Tech Tree canvas (set by dragging a node). Leave empty to use the automatic layout.
*/
treePosition?: {
x?: number | null;
y?: number | null;
};
/**
* Mark whether this technology is ready for developer approval and implementation into the live environment.
*/
@ -1598,6 +1616,27 @@ export interface Technology {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tech-categories".
*/
export interface TechCategory {
id: number;
/**
* Category label shown as a column band on the Tech Tree (e.g. Tier 1, Tier 2).
*/
name: string;
/**
* Timeline position: lower numbers sit further left on the Tech Tree. Categories with the same position are ordered alphabetically.
*/
position: number;
/**
* Optional hex accent color for the band on the Tech Tree (e.g. #22d3ee).
*/
color?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "labor-classifications".
@ -5732,6 +5771,24 @@ export interface PayloadMcpApiKey {
*/
delete?: boolean | null;
};
techCategories?: {
/**
* Allow clients to find tech-categories.
*/
find?: boolean | null;
/**
* Allow clients to create tech-categories.
*/
create?: boolean | null;
/**
* Allow clients to update tech-categories.
*/
update?: boolean | null;
/**
* Allow clients to delete tech-categories.
*/
delete?: boolean | null;
};
assets?: {
/**
* Allow clients to find assets.
@ -6735,6 +6792,10 @@ export interface PayloadLockedDocument {
relationTo: 'technologies';
value: number | Technology;
} | null)
| ({
relationTo: 'tech-categories';
value: number | TechCategory;
} | null)
| ({
relationTo: 'assets';
value: number | Asset;
@ -7836,6 +7897,13 @@ export interface FactionsSelect<T extends boolean = true> {
*/
export interface TechnologiesSelect<T extends boolean = true> {
name?: T;
category?: T;
treePosition?:
| T
| {
x?: T;
y?: T;
};
approvalStatus?: T;
type?: T;
summary?: T;
@ -7875,6 +7943,17 @@ export interface TechnologiesSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tech-categories_select".
*/
export interface TechCategoriesSelect<T extends boolean = true> {
name?: T;
position?: T;
color?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "assets_select".
@ -9513,6 +9592,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
update?: T;
delete?: T;
};
techCategories?:
| T
| {
find?: T;
create?: T;
update?: T;
delete?: T;
};
assets?:
| T
| {

View file

@ -229,6 +229,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "admin:technologies:manage", label: "Manage Admin Page" },
],
},
{
group: "Tech Categories",
permissions: [
{ value: "tech-categories:create", label: "Create" },
{ value: "tech-categories:read", label: "Read" },
{ value: "tech-categories:update", label: "Update" },
{ value: "tech-categories:delete", label: "Delete" },
{ value: "admin:tech-categories:manage", label: "Manage Admin Page" },
],
},
// ---- Logistics -----------------------------------------------------------
{
@ -814,6 +824,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"technologies:update",
"technologies:delete",
"admin:technologies:manage",
"tech-categories:create",
"tech-categories:read",
"tech-categories:update",
"tech-categories:delete",
"admin:tech-categories:manage",
"assets:create",
"assets:read",
"assets:update",

View file

@ -41,6 +41,7 @@ const DIVISION_ADMIN_QUALIFICATIONS: Record<
(payload: Payload, user: { id: number | string }) => Promise<boolean>
> = {
technologies: hasIntelligenceQualification,
"tech-categories": hasIntelligenceQualification,
};
/**