1
0
Fork 0

Compare commits

..

4 commits

47 changed files with 251284 additions and 219 deletions

2
.gitignore vendored
View file

@ -47,6 +47,8 @@ next-env.d.ts
.env .env
.env.stg .env.stg
.env.prd .env.prd
.env.development
.env.*
/media /media

View file

@ -185,7 +185,7 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- **Server actions**: `src/app/(frontend)/logistics/banking/actions.ts`. `createBankAccount` (regular users may only create their own personal account; treasury/faction require a manager), `ensureMyAccount`, `depositFunds`/`withdrawFunds`/`transferFunds`. Permission model: personal accounts are owner- or manager-only; **treasury/faction accounts are manager-only**. Manager = admin/developer or logistics-qualified (`hasLogisticsQualification`). Emits `finance:deposit` / `finance:withdraw` / `finance:transfer` / `bank:account-create` events. - **Server actions**: `src/app/(frontend)/logistics/banking/actions.ts`. `createBankAccount` (regular users may only create their own personal account; treasury/faction require a manager), `ensureMyAccount`, `depositFunds`/`withdrawFunds`/`transferFunds`. Permission model: personal accounts are owner- or manager-only; **treasury/faction accounts are manager-only**. Manager = admin/developer or logistics-qualified (`hasLogisticsQualification`). Emits `finance:deposit` / `finance:withdraw` / `finance:transfer` / `bank:account-create` events.
- **UI**: `src/app/(frontend)/logistics/banking/` (overview + `[id]` detail), components in `src/components/frontend/banking/` (`BankingOverview`, `AccountCard`, `AccountDetail`, `CreateAccountDialog`, `BankTransactionDialog`, `LedgerTable`, `MyWalletCard`). Sidebar entry "Banking" under Logistics. - **UI**: `src/app/(frontend)/logistics/banking/` (overview + `[id]` detail), components in `src/components/frontend/banking/` (`BankingOverview`, `AccountCard`, `AccountDetail`, `CreateAccountDialog`, `BankTransactionDialog`, `LedgerTable`, `MyWalletCard`). Sidebar entry "Banking" under Logistics.
- **Event targets**: `bank-accounts`, `bank-transactions`, `ledger-entries` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Finance event types in `eventTypes.ts`. - **Event targets**: `bank-accounts`, `bank-transactions`, `ledger-entries` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Finance event types in `eventTypes.ts`.
- **Gotchas**: Payload's create TS overloads reject `undefined` on relationship/required fields — pass `null` for empty relationships and a concrete value (`""` for `transactionNumber`) or TS falls through to the draft-variant and errors `Property 'draft' is missing`. No migration has been added for these collections yet (project relies on dev `push: true`). - **Gotchas**: Payload's create TS overloads reject `undefined` on relationship/required fields — pass `null` for empty relationships and a concrete value (`""` for `transactionNumber`) or TS falls through to the draft-variant and errors `Property 'draft' is missing`. No migration has been added for these collections yet (dev uses `push: false`; run `bun run payload migrate` after schema changes).
## Trading Marketplace ## Trading Marketplace
@ -213,7 +213,7 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
- **NPC attribution**: auto entries belong to a `game-npcs` NPC instead of a user. `src/lib/market/npcs.ts` — `resolveVendorNpc(payload, assetId)` prefers an in-game vendor NPC that `vendor.sells` the asset, falls back to any enabled vendor NPC selling it, otherwise `createGeneratedNpc` (random name/occupation, `isGenerated: true`, `isInGame: false`, set up as a vendor for that asset). `applyNpcPriceModifier(base, npc)` applies `vendor.priceModifier`. GMs create real NPCs in the admin panel and tick `isInGame` on generated placeholders to promote them. - **NPC attribution**: auto entries belong to a `game-npcs` NPC instead of a user. `src/lib/market/npcs.ts` — `resolveVendorNpc(payload, assetId)` prefers an in-game vendor NPC that `vendor.sells` the asset, falls back to any enabled vendor NPC selling it, otherwise `createGeneratedNpc` (random name/occupation, `isGenerated: true`, `isInGame: false`, set up as a vendor for that asset). `applyNpcPriceModifier(base, npc)` applies `vendor.priceModifier`. GMs create real NPCs in the admin panel and tick `isInGame` on generated placeholders to promote them.
- **UI**: `src/app/(frontend)/logistics/market/` page + `src/components/frontend/market/` (`MarketView` with search/rarity/type filters, `ListingCard` with Buy/Offer/Cancel, `CreateListingDialog`, `NegotiationPanel`, `MakeOfferDialog`, `NpcChat`, `Countdown`). Sidebar entry "Market" under Logistics. Uses `formatAmount` from `src/lib/banking/format` for prices. Auto entries show the NPC name + occupation in the "from" line (`npcLabel`), falling back to "Market vendor" for legacy entries with no NPC. - **UI**: `src/app/(frontend)/logistics/market/` page + `src/components/frontend/market/` (`MarketView` with search/rarity/type filters, `ListingCard` with Buy/Offer/Cancel, `CreateListingDialog`, `NegotiationPanel`, `MakeOfferDialog`, `NpcChat`, `Countdown`). Sidebar entry "Market" under Logistics. Uses `formatAmount` from `src/lib/banking/format` for prices. Auto entries show the NPC name + occupation in the "from" line (`npcLabel`), falling back to "Market vendor" for legacy entries with no NPC.
- **Event targets**: `market-listings` + `market-negotiations` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Market event types in `eventTypes.ts`. - **Event targets**: `market-listings` + `market-negotiations` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Market event types in `eventTypes.ts`.
- **Gotchas**: The `market-tick` bin must be run regularly (external cron) for expiry + vendor stock to update. `buyListing` credits the locker before clearing the payment — if the buyer's locker is full, the payment already moved and the purchase throws; consider escrow/refund handling in a later pass. When a seller accepts a buyer's offer, `buyListing` runs as the **buyer** (from `negotiation.buyer`), never the accepting actor. No migration added yet (dev relies on `push: true`; the `quantity` column on `market-negotiations` was added directly to the dev DB via `ALTER TABLE` because `bun run db push` fails with `must be owner of table spatial_ref_sys`). - **Gotchas**: The `market-tick` bin must be run regularly (external cron) for expiry + vendor stock to update. `buyListing` credits the locker before clearing the payment — if the buyer's locker is full, the payment already moved and the purchase throws; consider escrow/refund handling in a later pass. When a seller accepts a buyer's offer, `buyListing` runs as the **buyer** (from `negotiation.buyer`), never the accepting actor. No migration added yet (dev uses `push: false`; run `bun run payload migrate` after schema changes). `bun run db push` fails with `must be owner of table spatial_ref_sys` — use Payload's migration runner instead.
## Notifications ## Notifications
@ -268,7 +268,7 @@ Username-based login (no email login). Users log in via Payload admin with `user
PostgreSQL via `@payloadcms/db-postgres`. Schema defined in `src/payload-generated-schema.ts`. Migrations in `src/migrations/` (timestamp-named .ts + .json pairs, registered in `migrations/index.ts`). Drizzle config reads `DATABASE_URI` from env. PostgreSQL via `@payloadcms/db-postgres`. Schema defined in `src/payload-generated-schema.ts`. Migrations in `src/migrations/` (timestamp-named .ts + .json pairs, registered in `migrations/index.ts`). Drizzle config reads `DATABASE_URI` from env.
In development, `postgresAdapter` uses `push: true` to auto-sync schema. In development, `postgresAdapter` uses `push: false` — run `bun run payload migrate` after schema changes to apply them locally.
## Code style ## Code style
@ -302,7 +302,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
- Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e. - Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e.
- Payload admin layout and importMap are auto-generated — do not edit by hand. - Payload admin layout and importMap are auto-generated — do not edit by hand.
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility. - `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or start the dev server, whose Payload `push: true` path may skip the offending table. - `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or use Payload's migration runner (`bun run payload migrate`).
## Server Actions convention ## Server Actions convention

View file

@ -162,7 +162,7 @@ docker exec <container-name> node node_modules/payload/bin.js migrate
Coolify Scheduled Tasks support ad-hoc runs of the same command — fire it manually, watch the logs, then commit. **Never** run drizzle-kit (`bun run db migrate`) in prod; `bun run db push` fails on this DB because of the PostGIS `spatial_ref_sys` ownership issue (see `AGENTS.md` Gotchas). Use Payload's migration runner exclusively. Coolify Scheduled Tasks support ad-hoc runs of the same command — fire it manually, watch the logs, then commit. **Never** run drizzle-kit (`bun run db migrate`) in prod; `bun run db push` fails on this DB because of the PostGIS `spatial_ref_sys` ownership issue (see `AGENTS.md` Gotchas). Use Payload's migration runner exclusively.
For dev (where `push: true` in `payload.config.ts`), starting the dev server auto-applies schema changes — that's intentional and fine for dev databases. For dev (where `push: false` in `payload.config.ts`), run `bun run payload migrate` after schema changes to apply them locally.
--- ---

View file

@ -42,9 +42,24 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
collection: "campaigns", collection: "campaigns",
id: Number(id), id: Number(id),
depth: 1, depth: 1,
select: {
id: true,
name: true,
summary: true,
status: true,
startDate: true,
endDate: true,
owner: true,
},
}) })
.catch(() => null); .catch(() => null);
const owner = campaign?.owner;
const ownerName =
owner && typeof owner === "object" && "displayName" in owner
? owner.displayName
: "Unknown";
if (!campaign) { if (!campaign) {
notFound(); notFound();
} }
@ -237,7 +252,6 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
<span className="font-mono">{count}</span> <span className="font-mono">{count}</span>
</div> </div>
))} ))}
{campaign.startDate && (
<div className="flex justify-between text-sm pt-1 border-t border-border/50"> <div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span> <span className="text-muted-foreground">Duration</span>
<span> <span>
@ -245,7 +259,10 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
{endDate ? ` — ${endDate}` : " — Present"} {endDate ? ` — ${endDate}` : " — Present"}
</span> </span>
</div> </div>
)} <div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Owner</span>
<span className="font-medium">{ownerName}</span>
</div>
</div> </div>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -2,6 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { ShieldIcon } from "lucide-react"; import { ShieldIcon } from "lucide-react";
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard"; import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
import { CreateCampaignDialog } from "@/components/frontend/intelligence/CreateCampaignDialog";
import type { Media } from "@/payload-types"; import type { Media } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -17,6 +18,15 @@ export default async function CampaignsPage() {
sort: "-createdAt", sort: "-createdAt",
limit: 100, limit: 100,
depth: 1, depth: 1,
select: {
id: true,
name: true,
summary: true,
status: true,
startDate: true,
endDate: true,
owner: true,
},
}); });
const campaigns = campaignsRes.docs; const campaigns = campaignsRes.docs;
@ -60,12 +70,19 @@ export default async function CampaignsPage() {
: null; : null;
const coverImageUrl = coverImage?.url ?? null; const coverImageUrl = coverImage?.url ?? null;
const owner = (campaign as any).owner;
const ownerName =
owner && typeof owner === "object" && "displayName" in owner
? owner.displayName
: "Unknown";
return { return {
...campaign, ...campaign,
coverImageUrl, coverImageUrl,
missionCount, missionCount,
avgPlayers: missionCount === 0 ? 0 : totalPlayers / missionCount, avgPlayers: missionCount === 0 ? 0 : totalPlayers / missionCount,
avgDurationMinutes: missionCount === 0 ? 0 : totalDurationMinutes / missionCount, avgDurationMinutes: missionCount === 0 ? 0 : totalDurationMinutes / missionCount,
ownerName,
}; };
}), }),
); );
@ -85,12 +102,7 @@ export default async function CampaignsPage() {
</div> </div>
<div className="flex flex-col gap-1 justify-end text-center max-w-3xs"> <div className="flex flex-col gap-1 justify-end text-center max-w-3xs">
<p className="text-sm text-muted-foreground"> <CreateCampaignDialog />
Are you a Zeus and want to start your own campaign?
</p>
<Button variant="outline" size="sm">
Create your own campaign!
</Button>
</div> </div>
</div> </div>
{sorted.length > 0 ? ( {sorted.length > 0 ? (

View file

@ -20,10 +20,11 @@ import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext"; import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider"; import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
import { Rank } from "@/payload-types"; import { Rank, Shim } from "@/payload-types";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation"; import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner"; import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
// Skip static prerendering of this layout (and all pages under (frontend)/). // Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user, // The layout calls `getPayload()` at render time to resolve the current user,
@ -67,6 +68,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
let isLogistics = false; let isLogistics = false;
let currentRank = ""; let currentRank = "";
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
if (user) { if (user) {
[isIntelligence, isLogistics] = await Promise.all([ [isIntelligence, isLogistics] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false), hasIntelligenceQualification(payload, user).catch(() => false),
@ -152,6 +156,11 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
{user && <GameTickRealtime />} {user && <GameTickRealtime />}
{isLogistics && <ShipmentToasts />} {isLogistics && <ShipmentToasts />}
<Toaster /> <Toaster />
<ShimLoader
shims={shimEntries}
userId={user?.id ?? null}
userRoles={user?.roles ?? []}
/>
</body> </body>
</html> </html>
); );

View file

@ -11,6 +11,7 @@ import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/component
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor' import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client' import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { PromptEditorField as PromptEditorField_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_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay' import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon' import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo' import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
@ -34,6 +35,7 @@ export const importMap = {
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759, "@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73, "@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73, "@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7, "@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb, "@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04, "@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,

View file

@ -4,8 +4,8 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
export const Shims: GlobalConfig = { export const Shims: GlobalConfig = {
slug: "shims", slug: "shims",
access: { access: {
update: requirePermission("shims:update"),
read: requirePermission("shims:read"), read: requirePermission("shims:read"),
update: requirePermission("shims:update"),
}, },
fields: [ fields: [
{ {
@ -17,22 +17,137 @@ export const Shims: GlobalConfig = {
type: "text", type: "text",
required: true, required: true,
unique: true, unique: true,
admin: {
description: "Unique identifier for this shim. Used internally for stable hashing.",
},
}, },
{ {
name: "summary", name: "summary",
type: "text", type: "text",
admin: {
description: "Brief description of what this shim does (for admin reference).",
},
},
{
name: "enabled",
type: "checkbox",
defaultValue: false,
admin: {
description: "Disabled shims are never injected, regardless of targeting rules.",
},
},
{
name: "type",
type: "select",
options: [
{ label: "CSS", value: "css" },
{ label: "JavaScript", value: "js" },
],
required: true,
defaultValue: "css",
}, },
{ {
name: "content", name: "content",
type: "code", type: "code",
required: true, required: true,
admin: {
components: {
Field: "@/components/admin/shims/ShimContentField",
},
description:
"CSS or JS code to inject. CSS is added as a <style> tag; JS is added as an inline <script>.",
},
}, },
{ {
name: "type", name: "targeting",
type: "group",
label: "Targeting",
admin: {
description: "Who should this shim be applied to?",
},
fields: [
{
name: "audience",
type: "select", type: "select",
options: ["css", "js"],
required: true, required: true,
defaultValue: "css", defaultValue: "all",
options: [
{ label: "All Users", value: "all" },
{ label: "Percentage of Users", value: "percentage" },
{ label: "Specific Users", value: "users" },
{ label: "By Role", value: "roles" },
],
},
{
name: "percentage",
type: "number",
min: 0,
max: 100,
defaultValue: 50,
admin: {
condition: (_data, siblingData) => siblingData?.audience === "percentage",
description:
"Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.",
},
},
{
name: "users",
type: "relationship",
relationTo: "users",
hasMany: true,
admin: {
condition: (_data, siblingData) => siblingData?.audience === "users",
description: "Only these specific users will receive this shim.",
},
},
{
name: "roles",
type: "select",
hasMany: true,
options: [
{ label: "Guest", value: "guest" },
{ label: "User", value: "user" },
{ label: "Trusted", value: "trusted" },
{ label: "Admin", value: "admin" },
{ label: "Developer", value: "developer" },
],
admin: {
condition: (_data, siblingData) => siblingData?.audience === "roles",
description: "Users with any of these roles will receive this shim.",
},
},
],
},
{
name: "paths",
type: "group",
label: "Path Matching",
admin: {
description: "Which pages should this shim be active on?",
},
fields: [
{
name: "matchType",
type: "select",
required: true,
defaultValue: "all",
options: [
{ label: "All Pages", value: "all" },
{ label: "Include Only", value: "include" },
{ label: "Exclude", value: "exclude" },
],
},
{
name: "paths",
type: "text",
hasMany: true,
admin: {
condition: (_data, siblingData) => siblingData?.matchType !== "all",
description:
"URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.",
},
},
],
}, },
], ],
}, },

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission"; import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
import type { User } from "@/payload-types";
export const Campaigns: CollectionConfig = { export const Campaigns: CollectionConfig = {
slug: "campaigns", slug: "campaigns",
@ -8,10 +9,10 @@ export const Campaigns: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: requirePermission("campaigns:create"), create: ({ req }) => !!req.user, // Any logged-in user can create (becomes owner)
update: requirePermission("campaigns:update"), read: ({ req }) => !!req.user, // Anyone logged in can read
delete: requirePermission("campaigns:delete"), update: requireCampaignOwnership("campaigns:update"),
read: ({ req }) => !!req.user, delete: requireCampaignOwnership("campaigns:delete"),
}, },
fields: [ fields: [
{ {
@ -67,5 +68,14 @@ export const Campaigns: CollectionConfig = {
"Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.", "Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.",
}, },
}, },
{
name: "owner",
type: "relationship",
relationTo: "users",
admin: {
position: "sidebar",
description: "User who created this campaign. Set automatically on create.",
},
},
], ],
}; };

View file

@ -0,0 +1,30 @@
"use client";
import { CodeField, useFormFields } from "@payloadcms/ui";
import type { CodeFieldClientComponent } from "payload";
const languageForShimType = (type: unknown) =>
type === "css" ? "css" : "javascript";
/** Keeps Payload's standard code editor while selecting its language per shim row. */
export const ShimContentField: CodeFieldClientComponent = (props) => {
const typePath = props.path.replace(/\.content$/, ".type");
const shimType = useFormFields(([fields]) => fields[typePath]?.value);
const language = languageForShimType(shimType);
return (
<CodeField
{...props}
key={language}
field={{
...props.field,
admin: {
...props.field.admin,
language,
},
}}
/>
);
};
export default ShimContentField;

View file

@ -51,6 +51,12 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
roundedDuration >= 60 ? `${durationHours}h ${durationMinutes}m` : `${roundedDuration}m`; roundedDuration >= 60 ? `${durationHours}h ${durationMinutes}m` : `${roundedDuration}m`;
const playersLabel = Number.isInteger(avgPlayers) ? avgPlayers.toString() : avgPlayers.toFixed(1); const playersLabel = Number.isInteger(avgPlayers) ? avgPlayers.toString() : avgPlayers.toFixed(1);
const owner = (campaign as any).owner;
const ownerName =
owner && typeof owner === "object" && "displayName" in owner
? owner.displayName
: "Unknown";
return ( return (
<Link <Link
href={`/intelligence/campaigns/${campaign.id}`} href={`/intelligence/campaigns/${campaign.id}`}
@ -115,6 +121,12 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
<span className="sr-only">Avg duration</span> <span className="sr-only">Avg duration</span>
<span className="truncate">{durationLabel}</span> <span className="truncate">{durationLabel}</span>
</div> </div>
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 text-xs text-white/80">
<div className="text-right">
<div className="truncate">{ownerName}</div>
<span className="text-[10px] uppercase tracking-wider text-white/60">Owner</span>
</div>
</div>
</footer> </footer>
</article> </article>
</Link> </Link>

View file

@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { createCampaign } from "./create";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Shield, Loader2 } from "lucide-react";
export function CreateCampaignDialog() {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: "",
summary: "An Arma 3 campaign.",
status: "concept" as "concept" | "inProgress" | "completed",
description: "",
startDate: "",
endDate: "",
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const result = await createCampaign(formData);
setIsSubmitting(false);
if (result.success) {
setOpen(false);
setFormData({
name: "",
summary: "An Arma 3 campaign.",
status: "concept",
description: "",
startDate: "",
endDate: "",
});
window.location.href = "/intelligence/campaigns";
} else {
alert("Error creating campaign: " + (result.error || "Unknown error"));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Shield className="size-4" />
<span>Create campaign</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Campaign</DialogTitle>
<DialogDescription>
Create a new Arma 3 campaign to manage your operations.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Campaign Name <span className="text-red-500">*</span>
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter campaign name"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="summary" className="text-sm font-medium">
Summary <span className="text-red-500">*</span>
</label>
<Textarea
id="summary"
value={formData.summary}
onChange={(e) => setFormData({ ...formData, summary: e.target.value })}
placeholder="Brief description of the campaign"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="status" className="text-sm font-medium">
Status <span className="text-red-500">*</span>
</label>
<select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
required
>
<option value="concept">Concept</option>
<option value="inProgress">In Progress</option>
<option value="completed">Completed</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description
</label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional: Detailed description of the campaign"
/>
</div>
<div className="space-y-2">
<label htmlFor="startDate" className="text-sm font-medium">
Start Date
</label>
<Input
id="startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
max={formData.endDate}
/>
</div>
<div className="space-y-2">
<label htmlFor="endDate" className="text-sm font-medium">
End Date
</label>
<Input
id="endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
min={formData.startDate}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Campaign"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,72 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
interface CreateCampaignData {
name: string;
summary: string;
status: "concept" | "inProgress" | "completed";
startDate?: string;
endDate?: string;
description?: string;
}
async function authenticate() {
const payload = await getPayload({ config });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function createCampaign(data: CreateCampaignData) {
try {
const { payload, user } = await authenticate();
// Filter out undefined dates
const campaignData: Record<string, any> = {
name: data.name,
summary: data.summary,
status: data.status,
owner: user.id,
};
// Only add date fields if they have values
if (data.startDate) {
campaignData.startDate = data.startDate;
}
if (data.endDate) {
campaignData.endDate = data.endDate;
}
if (data.description) {
campaignData.description = data.description;
}
const campaign = await payload.create({
collection: "campaigns",
data: campaignData as any,
depth: 1,
});
return {
success: true,
data: campaign,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to create campaign",
};
}
}

View file

@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { isShimActive, type ShimEntry } from "@/lib/shims/evaluate";
interface ShimLoaderProps {
shims: ShimEntry[];
userId: number | null;
userRoles: string[];
}
const SHIM_ATTR = "data-ptf-shim";
/**
* Client component that evaluates active shims and injects their CSS/JS
* into the document <head>. Shims are re-evaluated on route changes.
*
* Shims are injected as <style data-ptf-shim="name"> (CSS) or
* <script data-ptf-shim="name"> (JS) elements. On cleanup or route change,
* previously injected elements are removed before new ones are added.
*/
export function ShimLoader({ shims, userId, userRoles }: ShimLoaderProps) {
const pathname = usePathname();
useEffect(() => {
// Remove any previously injected shims
const existing = document.querySelectorAll(`[${SHIM_ATTR}]`);
existing.forEach((el) => el.remove());
// Evaluate and inject active shims
for (const shim of shims) {
if (!isShimActive(shim, userId, userRoles, pathname)) continue;
if (shim.type === "css") {
const style = document.createElement("style");
style.setAttribute(SHIM_ATTR, shim.name);
style.textContent = shim.content;
document.head.appendChild(style);
} else if (shim.type === "js") {
const script = document.createElement("script");
script.setAttribute(SHIM_ATTR, shim.name);
script.textContent = shim.content;
document.head.appendChild(script);
}
}
// Cleanup: remove all injected shims on unmount
return () => {
document.querySelectorAll(`[${SHIM_ATTR}]`).forEach((el) => el.remove());
};
}, [shims, userId, userRoles, pathname]);
// No visible output — all work happens in the DOM
return null;
}

153
src/lib/shims/evaluate.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* Shims targeting evaluation — pure functions, no server dependencies.
*
* Determines whether a given shim should be active for a specific user on
* a specific path. All functions are side-effect free and safe to call in
* client components.
*/
// ---------------------------------------------------------------------------
// Types (mirrors what Payload will generate for the shims global)
// ---------------------------------------------------------------------------
export type ShimAudience = "all" | "percentage" | "users" | "roles";
export type ShimPathMatch = "all" | "include" | "exclude";
export interface ShimTargeting {
audience: ShimAudience;
/** 0–100, used when audience = "percentage". */
percentage?: number | null;
/** User IDs, used when audience = "users". */
users?: Array<number | { id: number }> | null;
/** Role strings, used when audience = "roles". */
roles?: string[] | null;
}
export interface ShimPathConfig {
matchType: ShimPathMatch;
paths?: string[] | null;
}
export interface ShimEntry {
name: string;
summary?: string | null;
enabled?: boolean | null;
type: "css" | "js";
content: string;
targeting: ShimTargeting;
paths?: ShimPathConfig | null;
}
// ---------------------------------------------------------------------------
// Deterministic hash — djb2
// ---------------------------------------------------------------------------
/**
* Simple string hash → unsigned 32-bit integer.
* Deterministic: same input always produces the same output.
* Used for stable per-user percentage assignments.
*/
export function djb2Hash(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
}
/**
* Returns a value in [0, 100) for a given userId + shimName combination.
* Deterministic and uniform enough for percentage rollouts.
*/
export function hashForUser(userId: number, shimName: string): number {
const hash = djb2Hash(`${userId}:${shimName}`);
return hash % 100;
}
// ---------------------------------------------------------------------------
// Path matching
// ---------------------------------------------------------------------------
/**
* Checks whether the current pathname matches the shim's path config.
*
* - matchType "all" → always matches.
* - matchType "include" → pathname must start with one of the listed prefixes.
* - matchType "exclude" → pathname must NOT start with any listed prefix.
*/
export function matchesPath(pathname: string, pathConfig?: ShimPathConfig | null): boolean {
if (!pathConfig || pathConfig.matchType === "all") return true;
const prefixes = pathConfig.paths ?? [];
if (prefixes.length === 0) return true;
const matched = prefixes.some((prefix) => {
const normalized = prefix.startsWith("/") ? prefix : `/${prefix}`;
return pathname === normalized || pathname.startsWith(`${normalized}/`);
});
return pathConfig.matchType === "include" ? matched : !matched;
}
// ---------------------------------------------------------------------------
// Audience targeting
// ---------------------------------------------------------------------------
/**
* Checks whether a user is in the shim's target audience.
* Does NOT check enabled or path — call matchesPath() and check enabled separately.
*/
export function matchesAudience(
shim: ShimEntry,
userId: number | null,
userRoles: string[],
): boolean {
if (!shim.targeting) return true;
const { audience } = shim.targeting;
switch (audience) {
case "all":
return true;
case "percentage": {
if (userId === null) return false;
const threshold = shim.targeting.percentage ?? 0;
return hashForUser(userId, shim.name) < threshold;
}
case "users": {
if (userId === null) return false;
const targetUsers = shim.targeting.users ?? [];
return targetUsers.some((u) => (typeof u === "object" ? u.id : u) === userId);
}
case "roles": {
const targetRoles = shim.targeting.roles ?? [];
return targetRoles.some((role) => userRoles.includes(role));
}
default:
return true;
}
}
// ---------------------------------------------------------------------------
// Full evaluation
// ---------------------------------------------------------------------------
/**
* Returns true if the shim should be active for this user on this path.
* Checks: enabled → audience targeting → path matching.
*/
export function isShimActive(
shim: ShimEntry,
userId: number | null,
userRoles: string[],
pathname: string,
): boolean {
if (!shim.enabled) return false;
if (!matchesAudience(shim, userId, userRoles)) return false;
if (!matchesPath(pathname, shim.paths)) return false;
return true;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,175 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
/**
* Adds targeting, path matching, and enabled fields to the shims global.
* This is a diff migration — production already has the base shims_shims table
* with (name, summary, content, type). This adds the new columns, enum types,
* and junction tables without recreating anything that already exists.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
// --- New enum types ---
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_shims_shims_targeting_audience"
AS ENUM('all', 'percentage', 'users', 'roles');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_shims_shims_targeting_roles"
AS ENUM('guest', 'user', 'trusted', 'admin', 'developer');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_shims_shims_paths_match_type"
AS ENUM('all', 'include', 'exclude');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
// --- New columns on shims_shims ---
await db.execute(sql`
ALTER TABLE "shims_shims"
ADD COLUMN IF NOT EXISTS "enabled" boolean DEFAULT false NOT NULL;
`);
await db.execute(sql`
ALTER TABLE "shims_shims"
ADD COLUMN IF NOT EXISTS "targeting_audience"
"enum_shims_shims_targeting_audience" DEFAULT 'all' NOT NULL;
`);
await db.execute(sql`
ALTER TABLE "shims_shims"
ADD COLUMN IF NOT EXISTS "targeting_percentage" numeric;
`);
await db.execute(sql`
ALTER TABLE "shims_shims"
ADD COLUMN IF NOT EXISTS "paths_match_type"
"enum_shims_shims_paths_match_type" DEFAULT 'all' NOT NULL;
`);
// --- Junction table: shims_shims_targeting_roles (hasMany select) ---
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "shims_shims_targeting_roles" (
"order" integer NOT NULL,
"parent_id" varchar NOT NULL,
"value" "enum_shims_shims_targeting_roles",
"id" serial PRIMARY KEY NOT NULL
);
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "shims_shims_targeting_roles"
ADD CONSTRAINT "shims_shims_targeting_roles_parent_id_fk"
FOREIGN KEY ("parent_id") REFERENCES "public"."shims_shims"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "shims_shims_targeting_roles_order_idx"
ON "shims_shims_targeting_roles" USING btree ("order");
CREATE INDEX IF NOT EXISTS "shims_shims_targeting_roles_parent_id_idx"
ON "shims_shims_targeting_roles" USING btree ("parent_id");
`);
// --- Junction table: shims_shims_targeting_users (hasMany relationship) ---
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "shims_shims_targeting_users" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"value" integer NOT NULL
);
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "shims_shims_targeting_users"
ADD CONSTRAINT "shims_shims_targeting_users_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "public"."shims_shims"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "shims_shims_targeting_users"
ADD CONSTRAINT "shims_shims_targeting_users_value_fk"
FOREIGN KEY ("value") REFERENCES "public"."users"("id")
ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "shims_shims_targeting_users_order_idx"
ON "shims_shims_targeting_users" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "shims_shims_targeting_users_parent_id_idx"
ON "shims_shims_targeting_users" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "shims_shims_targeting_users_value_idx"
ON "shims_shims_targeting_users" USING btree ("value");
`);
// --- Junction table: shims_shims_paths_paths (hasMany text) ---
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "shims_shims_paths_paths" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"value" varchar
);
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "shims_shims_paths_paths"
ADD CONSTRAINT "shims_shims_paths_paths_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "public"."shims_shims"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "shims_shims_paths_paths_order_idx"
ON "shims_shims_paths_paths" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "shims_shims_paths_paths_parent_id_idx"
ON "shims_shims_paths_paths" USING btree ("_parent_id");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Drop junction tables (cascade removes FK rows)
await db.execute(sql`DROP TABLE IF EXISTS "shims_shims_paths_paths";`);
await db.execute(sql`DROP TABLE IF EXISTS "shims_shims_targeting_users";`);
await db.execute(sql`DROP TABLE IF EXISTS "shims_shims_targeting_roles";`);
// Drop columns
await db.execute(sql`
ALTER TABLE "shims_shims" DROP COLUMN IF EXISTS "enabled";
ALTER TABLE "shims_shims" DROP COLUMN IF EXISTS "targeting_audience";
ALTER TABLE "shims_shims" DROP COLUMN IF EXISTS "targeting_percentage";
ALTER TABLE "shims_shims" DROP COLUMN IF EXISTS "paths_match_type";
`);
// Drop enum types
await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_shims_shims_paths_match_type";`);
await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_shims_shims_targeting_roles";`);
await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_shims_shims_targeting_audience";`);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
/**
* Repairs auxiliary tables required by Payload's current Shims global schema.
*
* The original Shims migrations created the global and its array/targeting
* tables, but the later relationship/text fields also require these two
* Payload-managed tables. Existing databases need this additive repair.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "shims_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
CREATE TABLE IF NOT EXISTS "shims_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "shims_texts"
ADD CONSTRAINT "shims_texts_parent_fk"
FOREIGN KEY ("parent_id") REFERENCES "public"."shims"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "shims_rels"
ADD CONSTRAINT "shims_rels_parent_fk"
FOREIGN KEY ("parent_id") REFERENCES "public"."shims"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "shims_rels"
ADD CONSTRAINT "shims_rels_users_fk"
FOREIGN KEY ("users_id") REFERENCES "public"."users"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "shims_texts_order_parent"
ON "shims_texts" USING btree ("order", "parent_id");
CREATE INDEX IF NOT EXISTS "shims_rels_order_idx"
ON "shims_rels" USING btree ("order");
CREATE INDEX IF NOT EXISTS "shims_rels_parent_idx"
ON "shims_rels" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "shims_rels_path_idx"
ON "shims_rels" USING btree ("path");
CREATE INDEX IF NOT EXISTS "shims_rels_users_id_idx"
ON "shims_rels" USING btree ("users_id");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE IF EXISTS "shims_rels";
DROP TABLE IF EXISTS "shims_texts";
`);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,276 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// Idempotent migration: safe to run on databases where push: true already applied
// the schema (dev), and on fresh production databases that need these objects.
// Each DDL block is wrapped in exception handlers to skip already-existing objects.
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// --- Enums ---
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Tables ---
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "users_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"roles_id" integer
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "roles_permissions" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_roles_permissions",
"id" serial PRIMARY KEY NOT NULL
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "roles" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"slug" varchar NOT NULL,
"description" varchar,
"is_system" boolean DEFAULT false,
"is_superuser" boolean DEFAULT false,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "roles_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"roles_id" integer
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Columns (ADD COLUMN with DEFAULT for NOT NULL columns to handle existing rows) ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead';
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone DEFAULT now() NOT NULL;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
// --- Foreign keys ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Indexes (catch duplicate_table OR duplicate_object — PostgreSQL uses 42P07 for indexes) ---
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "users_rels" CASCADE;
DROP TABLE "roles_permissions" CASCADE;
DROP TABLE "roles" CASCADE;
DROP TABLE "roles_rels" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk";
DROP INDEX "payload_locked_documents_rels_roles_id_idx";
ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual";
ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay";
ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id";
DROP TYPE "public"."enum_users_preferences_display_lead_or_actual";
DROP TYPE "public"."enum_roles_permissions";
`)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,510 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// Idempotent migration: safe to run on databases where push: true already applied
// the schema (dev), and on production databases where prior migrations already ran.
// Each DDL block is wrapped in exception handlers to skip already-existing objects.
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// --- Enums ---
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_game_servers_status" AS ENUM('online', 'offline');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_arma_commands_status" AS ENUM('queued', 'delivered', 'succeeded', 'failed');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'campaigns:manage' BEFORE 'factions:create';
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Tables ---
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "profiles_progression_awards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"award_id" integer NOT NULL,
"awarded_by_id" integer,
"awarded_at" timestamp(3) with time zone NOT NULL,
"award_reason" varchar
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "game_servers" (
"id" serial PRIMARY KEY NOT NULL,
"server_id" varchar NOT NULL,
"name" varchar NOT NULL,
"status" "enum_game_servers_status" DEFAULT 'offline' NOT NULL,
"last_seen_at" timestamp(3) with time zone,
"metadata" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "arma_sync_events" (
"id" serial PRIMARY KEY NOT NULL,
"event_id" varchar NOT NULL,
"server_id" integer NOT NULL,
"type" varchar NOT NULL,
"occurred_at" timestamp(3) with time zone NOT NULL,
"payload" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "arma_commands" (
"id" serial PRIMARY KEY NOT NULL,
"command_id" varchar NOT NULL,
"server_id" integer NOT NULL,
"type" varchar NOT NULL,
"payload" jsonb NOT NULL,
"status" "enum_arma_commands_status" DEFAULT 'queued' NOT NULL,
"result" jsonb,
"error" varchar,
"delivered_at" timestamp(3) with time zone,
"completed_at" timestamp(3) with time zone,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE TABLE "payload_mcp_api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"label" varchar,
"description" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"enable_a_p_i_key" boolean,
"api_key" varchar,
"api_key_index" varchar
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Drop existing constraint/index/column (may already be dropped by prior migrations) ---
await db.execute(sql`
ALTER TABLE "profiles_rels" DROP CONSTRAINT IF EXISTS "profiles_rels_awards_fk";
`)
await db.execute(sql`
DROP INDEX IF EXISTS "profiles_rels_awards_id_idx";
`)
// --- Columns ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users" ADD COLUMN "preferences_display_xp_display_mini" boolean DEFAULT false;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "missions_mission_objectives" ADD COLUMN "redacted" boolean DEFAULT false;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "missions" ADD COLUMN "discord_attendance_sent_at" timestamp(3) with time zone;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "campaigns" ADD COLUMN "owner_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "game_servers_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "arma_sync_events_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "arma_commands_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "payload_mcp_api_keys_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_preferences_rels" ADD COLUMN "payload_mcp_api_keys_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
// --- Foreign keys (profiles_progression_awards) ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "profiles_progression_awards" ADD CONSTRAINT "profiles_progression_awards_award_id_awards_id_fk" FOREIGN KEY ("award_id") REFERENCES "public"."awards"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "profiles_progression_awards" ADD CONSTRAINT "profiles_progression_awards_awarded_by_id_profiles_id_fk" FOREIGN KEY ("awarded_by_id") REFERENCES "public"."profiles"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "profiles_progression_awards" ADD CONSTRAINT "profiles_progression_awards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Foreign keys (arma tables) ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "arma_sync_events" ADD CONSTRAINT "arma_sync_events_server_id_game_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."game_servers"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "arma_commands" ADD CONSTRAINT "arma_commands_server_id_game_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."game_servers"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_mcp_api_keys" ADD CONSTRAINT "payload_mcp_api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Indexes (profiles_progression_awards) ---
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "profiles_progression_awards_order_idx" ON "profiles_progression_awards" USING btree ("_order");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "profiles_progression_awards_parent_id_idx" ON "profiles_progression_awards" USING btree ("_parent_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "profiles_progression_awards_award_idx" ON "profiles_progression_awards" USING btree ("award_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "profiles_progression_awards_awarded_by_idx" ON "profiles_progression_awards" USING btree ("awarded_by_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Indexes (game_servers) ---
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "game_servers_server_id_idx" ON "game_servers" USING btree ("server_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "game_servers_updated_at_idx" ON "game_servers" USING btree ("updated_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "game_servers_created_at_idx" ON "game_servers" USING btree ("created_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Indexes (arma_sync_events) ---
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "arma_sync_events_event_id_idx" ON "arma_sync_events" USING btree ("event_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_sync_events_server_idx" ON "arma_sync_events" USING btree ("server_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_sync_events_type_idx" ON "arma_sync_events" USING btree ("type");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_sync_events_updated_at_idx" ON "arma_sync_events" USING btree ("updated_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_sync_events_created_at_idx" ON "arma_sync_events" USING btree ("created_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Indexes (arma_commands) ---
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "arma_commands_command_id_idx" ON "arma_commands" USING btree ("command_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_commands_server_idx" ON "arma_commands" USING btree ("server_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_commands_updated_at_idx" ON "arma_commands" USING btree ("updated_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "arma_commands_created_at_idx" ON "arma_commands" USING btree ("created_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Indexes (payload_mcp_api_keys) ---
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_mcp_api_keys_user_idx" ON "payload_mcp_api_keys" USING btree ("user_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_mcp_api_keys_updated_at_idx" ON "payload_mcp_api_keys" USING btree ("updated_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_mcp_api_keys_created_at_idx" ON "payload_mcp_api_keys" USING btree ("created_at");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Foreign keys (campaigns) ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Foreign keys (payload locked/preferences rels) ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_game_servers_fk" FOREIGN KEY ("game_servers_id") REFERENCES "public"."game_servers"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_arma_sync_events_fk" FOREIGN KEY ("arma_sync_events_id") REFERENCES "public"."arma_sync_events"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_arma_commands_fk" FOREIGN KEY ("arma_commands_id") REFERENCES "public"."arma_commands"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_payload_mcp_api_keys_fk" FOREIGN KEY ("payload_mcp_api_keys_id") REFERENCES "public"."payload_mcp_api_keys"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_payload_mcp_api_keys_fk" FOREIGN KEY ("payload_mcp_api_keys_id") REFERENCES "public"."payload_mcp_api_keys"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Remaining indexes ---
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "campaigns_owner_idx" ON "campaigns" USING btree ("owner_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_locked_documents_rels_game_servers_id_idx" ON "payload_locked_documents_rels" USING btree ("game_servers_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_locked_documents_rels_arma_sync_events_id_idx" ON "payload_locked_documents_rels" USING btree ("arma_sync_events_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_locked_documents_rels_arma_commands_id_idx" ON "payload_locked_documents_rels" USING btree ("arma_commands_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_locked_documents_rels_payload_mcp_api_keys_id_idx" ON "payload_locked_documents_rels" USING btree ("payload_mcp_api_keys_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_preferences_rels_payload_mcp_api_keys_id_idx" ON "payload_preferences_rels" USING btree ("payload_mcp_api_keys_id");
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Drop old column (may already be dropped by prior migrations) ---
await db.execute(sql`
ALTER TABLE "profiles_rels" DROP COLUMN IF EXISTS "awards_id";
`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles_progression_awards" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "game_servers" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "arma_sync_events" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "arma_commands" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_mcp_api_keys" DISABLE ROW LEVEL SECURITY;
DROP TABLE "profiles_progression_awards" CASCADE;
DROP TABLE "game_servers" CASCADE;
DROP TABLE "arma_sync_events" CASCADE;
DROP TABLE "arma_commands" CASCADE;
DROP TABLE "payload_mcp_api_keys" CASCADE;
ALTER TABLE "campaigns" DROP CONSTRAINT "campaigns_owner_id_users_id_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_game_servers_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_arma_sync_events_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_arma_commands_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_payload_mcp_api_keys_fk";
ALTER TABLE "payload_preferences_rels" DROP CONSTRAINT "payload_preferences_rels_payload_mcp_api_keys_fk";
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_roles_permissions";
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'game-rules:create', 'game-rules:read', 'game-rules:update', 'game-rules:delete');
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE "public"."enum_roles_permissions" USING "value"::"public"."enum_roles_permissions";
DROP INDEX "campaigns_owner_idx";
DROP INDEX "payload_locked_documents_rels_game_servers_id_idx";
DROP INDEX "payload_locked_documents_rels_arma_sync_events_id_idx";
DROP INDEX "payload_locked_documents_rels_arma_commands_id_idx";
DROP INDEX "payload_locked_documents_rels_payload_mcp_api_keys_id_idx";
DROP INDEX "payload_preferences_rels_payload_mcp_api_keys_id_idx";
ALTER TABLE "profiles_rels" ADD COLUMN "awards_id" integer;
ALTER TABLE "profiles_rels" ADD CONSTRAINT "profiles_rels_awards_fk" FOREIGN KEY ("awards_id") REFERENCES "public"."awards"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "profiles_rels_awards_id_idx" ON "profiles_rels" USING btree ("awards_id");
ALTER TABLE "users" DROP COLUMN "preferences_display_xp_display_mini";
ALTER TABLE "missions_mission_objectives" DROP COLUMN "redacted";
ALTER TABLE "missions" DROP COLUMN "discord_attendance_sent_at";
ALTER TABLE "campaigns" DROP COLUMN "owner_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "game_servers_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "arma_sync_events_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "arma_commands_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "payload_mcp_api_keys_id";
ALTER TABLE "payload_preferences_rels" DROP COLUMN "payload_mcp_api_keys_id";
DROP TYPE "public"."enum_game_servers_status";
DROP TYPE "public"."enum_arma_commands_status";`)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,21 @@
import * as migration_20260818_024510 from './_archived/20260818_024510'; import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260821_043007 from './20260821_043007'; import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260821_060000 from './20260821_060000';
import * as migration_20260821_120000 from './20260821_120000';
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: '20260820_004157', name: "20260820_004157",
}, },
{ {
up: migration_20260821_043007.up, up: migration_20260825_220000.up,
down: migration_20260821_043007.down, down: migration_20260825_220000.down,
name: '20260821_043007' name: "20260825_220000",
}, },
{ {
up: migration_20260821_060000.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260821_060000.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260821_060000' name: "20260826_050000_repair_shims_auxiliary_tables",
},
{
up: migration_20260821_120000.up,
down: migration_20260821_120000.down,
name: '20260821_120000'
}, },
]; ];

View file

@ -1118,171 +1118,10 @@ export interface Campaign {
* Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square. * Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.
*/ */
coverImage?: (number | null) | Media; coverImage?: (number | null) | Media;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * User who created this campaign. Set automatically on create.
* via the `definition` "game-vehicles".
*/ */
export interface GameVehicle { owner?: (number | null) | User;
id: number;
/**
* Display name or callsign for this vehicle instance.
*/
name: string;
/**
* The vehicle type/template this instance is based on.
*/
type: number | Vehicle;
/**
* The structure where this vehicle is currently stationed. Null if in transit.
*/
deployedAt?: (number | null) | GameStructure;
/**
* The faction that owns this vehicle.
*/
faction?: (number | null) | Faction;
status: 'idle' | 'assigned' | 'in_transit' | 'damaged' | 'destroyed';
/**
* Current fuel level. Must be between 0 and the vehicle type's fuel capacity.
*/
currentFuel: number;
/**
* Current health. Must be between 0 and the vehicle type's max health.
*/
currentHealth: number;
storedResources?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
/**
* Items displaced by grid resizing.
*/
voidStorage?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-npcs".
*/
export interface GameNpc {
id: number;
/**
* The NPC's name as shown in-game and on the frontend.
*/
name: string;
/**
* What this NPC does (e.g. 'Weapons Dealer', 'Field Medic').
*/
occupation?: string | null;
/**
* Where this NPC is stationed in-game.
*/
location?: (number | null) | GameStructure;
/**
* The faction this NPC belongs to, if any.
*/
faction?: (number | null) | Faction;
/**
* A short background/profile for this NPC.
*/
bio?: string | null;
/**
* True for NPCs auto-created by the market tick to cover unstocked goods. They are placeholders until included in the game.
*/
isGenerated?: boolean | null;
/**
* Whether this NPC is officially part of the game. Generated NPCs start off; flip this on to include them.
*/
isInGame?: boolean | null;
vendor?: {
/**
* Whether this NPC runs a market stall. Auto-generated vendor stock is attributed to them.
*/
enabled?: boolean | null;
/**
* Multiplies the auto-generated price for this NPC's stock (1 = base market price).
*/
priceModifier?: number | null;
/**
* The goods this NPC sells. The market tick prefers these NPCs when stocking those assets.
*/
sells?: (number | Asset)[] | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-event-logs".
*/
export interface GameEventLog {
id: number;
/**
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
*/
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: (number | null) | User;
structure?: (number | null) | GameStructure;
/**
* Which collection the event primarily relates to.
*/
targetCollection?:
| (
| 'game-structures'
| 'game-vehicles'
| 'game-npcs'
| 'structures'
| 'resources'
| 'assets'
| 'vehicles'
| 'factions'
| 'missions'
| 'campaigns'
| 'users'
| 'technologies'
| 'maps'
| 'shipments'
| 'bank-accounts'
| 'bank-transactions'
| 'ledger-entries'
| 'locker-storages'
| 'loadouts'
| 'market-listings'
| 'market-negotiations'
| 'tickets'
)
| null;
targetId?: number | null;
data?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@ -1454,6 +1293,7 @@ export interface Role {
| 'campaigns:read' | 'campaigns:read'
| 'campaigns:update' | 'campaigns:update'
| 'campaigns:delete' | 'campaigns:delete'
| 'campaigns:manage'
| 'factions:create' | 'factions:create'
| 'factions:read' | 'factions:read'
| 'factions:update' | 'factions:update'
@ -1594,6 +1434,171 @@ export interface Role {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-vehicles".
*/
export interface GameVehicle {
id: number;
/**
* Display name or callsign for this vehicle instance.
*/
name: string;
/**
* The vehicle type/template this instance is based on.
*/
type: number | Vehicle;
/**
* The structure where this vehicle is currently stationed. Null if in transit.
*/
deployedAt?: (number | null) | GameStructure;
/**
* The faction that owns this vehicle.
*/
faction?: (number | null) | Faction;
status: 'idle' | 'assigned' | 'in_transit' | 'damaged' | 'destroyed';
/**
* Current fuel level. Must be between 0 and the vehicle type's fuel capacity.
*/
currentFuel: number;
/**
* Current health. Must be between 0 and the vehicle type's max health.
*/
currentHealth: number;
storedResources?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
/**
* Items displaced by grid resizing.
*/
voidStorage?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-npcs".
*/
export interface GameNpc {
id: number;
/**
* The NPC's name as shown in-game and on the frontend.
*/
name: string;
/**
* What this NPC does (e.g. 'Weapons Dealer', 'Field Medic').
*/
occupation?: string | null;
/**
* Where this NPC is stationed in-game.
*/
location?: (number | null) | GameStructure;
/**
* The faction this NPC belongs to, if any.
*/
faction?: (number | null) | Faction;
/**
* A short background/profile for this NPC.
*/
bio?: string | null;
/**
* True for NPCs auto-created by the market tick to cover unstocked goods. They are placeholders until included in the game.
*/
isGenerated?: boolean | null;
/**
* Whether this NPC is officially part of the game. Generated NPCs start off; flip this on to include them.
*/
isInGame?: boolean | null;
vendor?: {
/**
* Whether this NPC runs a market stall. Auto-generated vendor stock is attributed to them.
*/
enabled?: boolean | null;
/**
* Multiplies the auto-generated price for this NPC's stock (1 = base market price).
*/
priceModifier?: number | null;
/**
* The goods this NPC sells. The market tick prefers these NPCs when stocking those assets.
*/
sells?: (number | Asset)[] | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-event-logs".
*/
export interface GameEventLog {
id: number;
/**
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
*/
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: (number | null) | User;
structure?: (number | null) | GameStructure;
/**
* Which collection the event primarily relates to.
*/
targetCollection?:
| (
| 'game-structures'
| 'game-vehicles'
| 'game-npcs'
| 'structures'
| 'resources'
| 'assets'
| 'vehicles'
| 'factions'
| 'missions'
| 'campaigns'
| 'users'
| 'technologies'
| 'maps'
| 'shipments'
| 'bank-accounts'
| 'bank-transactions'
| 'ledger-entries'
| 'locker-storages'
| 'loadouts'
| 'market-listings'
| 'market-negotiations'
| 'tickets'
)
| null;
targetId?: number | null;
data?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ranks". * via the `definition` "ranks".
@ -3663,6 +3668,7 @@ export interface CampaignsSelect<T extends boolean = true> {
startDate?: T; startDate?: T;
endDate?: T; endDate?: T;
coverImage?: T; coverImage?: T;
owner?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
@ -4530,10 +4536,51 @@ export interface Shim {
id: number; id: number;
shims?: shims?:
| { | {
/**
* Unique identifier for this shim. Used internally for stable hashing.
*/
name: string; name: string;
/**
* Brief description of what this shim does (for admin reference).
*/
summary?: string | null; summary?: string | null;
content: string; /**
* Disabled shims are never injected, regardless of targeting rules.
*/
enabled?: boolean | null;
type: 'css' | 'js'; type: 'css' | 'js';
/**
* CSS or JS code to inject. CSS is added as a <style> tag; JS is added as an inline <script>.
*/
content: string;
/**
* Who should this shim be applied to?
*/
targeting: {
audience: 'all' | 'percentage' | 'users' | 'roles';
/**
* Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.
*/
percentage?: number | null;
/**
* Only these specific users will receive this shim.
*/
users?: (number | User)[] | null;
/**
* Users with any of these roles will receive this shim.
*/
roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null;
};
/**
* Which pages should this shim be active on?
*/
paths: {
matchType: 'all' | 'include' | 'exclude';
/**
* URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.
*/
paths?: string[] | null;
};
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
@ -4566,8 +4613,23 @@ export interface ShimsSelect<T extends boolean = true> {
| { | {
name?: T; name?: T;
summary?: T; summary?: T;
content?: T; enabled?: T;
type?: T; type?: T;
content?: T;
targeting?:
| T
| {
audience?: T;
percentage?: T;
users?: T;
roles?: T;
};
paths?:
| T
| {
matchType?: T;
paths?: T;
};
id?: T; id?: T;
}; };
updatedAt?: T; updatedAt?: T;

View file

@ -264,7 +264,8 @@ export default buildConfig({
pool: { pool: {
connectionString: process.env.DATABASE_URI || "", connectionString: process.env.DATABASE_URI || "",
}, },
push: process.env.NODE_ENV === "development", // push: process.env.NODE_ENV === "development",
push: false,
prodMigrations: migrations, prodMigrations: migrations,
}), }),
sharp, sharp,

View file

@ -142,6 +142,10 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "campaigns:delete", label: "Delete" }, { value: "campaigns:delete", label: "Delete" },
], ],
}, },
{
group: "Campaigns",
permissions: [{ value: "campaigns:manage", label: "Manage Campaigns (owner bypass)" }],
},
{ {
group: "Factions", group: "Factions",
permissions: [ permissions: [
@ -512,6 +516,7 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"campaigns:read", "campaigns:read",
"campaigns:update", "campaigns:update",
"campaigns:delete", "campaigns:delete",
"campaigns:manage",
"factions:create", "factions:create",
"factions:read", "factions:read",
"factions:update", "factions:update",
@ -628,6 +633,7 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"game-rules:update", "game-rules:update",
"shims:read", "shims:read",
"shims:update", "shims:update",
"campaigns:manage",
"logistics:manage", "logistics:manage",
"banking:manage", "banking:manage",
"tickets:staff", "tickets:staff",

View file

@ -14,6 +14,7 @@ const USER_PERMISSIONS: Permission[] = [
"missions:read", "missions:read",
"mission-attendances:read", "mission-attendances:read",
"campaigns:read", "campaigns:read",
"campaigns:manage",
"factions:read", "factions:read",
"technologies:read", "technologies:read",
"assets:read", "assets:read",

View file

@ -111,3 +111,52 @@ export function requireAnyPermission(...permissions: Permission[]) {
return hasAnyPermission(req.payload, req.user, ...permissions); return hasAnyPermission(req.payload, req.user, ...permissions);
}; };
} }
/**
* Factory that creates a Payload collection access function requiring campaign ownership.
*
* A user can access a campaign if:
* 1. They own the campaign (owner field matches their user ID), OR
* 2. They have the "campaigns:manage" permission
*
* @example
* access: {
* update: requireCampaignOwnership("campaigns:update"),
* delete: requireCampaignOwnership("campaigns:delete"),
* }
*/
export function requireCampaignOwnership(permission: Permission) {
return async ({ req, id }: { req: PayloadRequest; id?: any }) => {
if (!req.user) return false;
// Check permission first (grants access to all campaigns for managers)
const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(req.payload, req.user);
if (su || userPerms.has(permission)) return true;
// If no user permissions for manage, check ownership
let campaign;
try {
campaign = await req.payload.findByID({
collection: "campaigns",
id: id,
depth: 1,
overrideAccess: true,
});
} catch (error) {
// If campaign lookup fails (e.g. invalid ID), deny access
return false;
}
if (!campaign) return false;
// User owns the campaign if owner field matches their ID
// campaign.owner can be number (ID) or User object
const campaignOwnerId = campaign.owner ? (typeof campaign.owner === "object" ? campaign.owner.id : campaign.owner) : null;
const userId = Number(req.user.id);
if (campaignOwnerId && campaignOwnerId === userId) return true;
// User does not own this campaign and lacks manage permission
return false;
};
}