Compare commits
4 commits
d6b2ffa5d2
...
413a677306
| Author | SHA1 | Date | |
|---|---|---|---|
| 413a677306 | |||
| b22a1836e4 | |||
| 83f203d736 | |||
| 0771424446 |
47 changed files with 251284 additions and 219 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -47,6 +47,8 @@ next-env.d.ts
|
||||||
.env
|
.env
|
||||||
.env.stg
|
.env.stg
|
||||||
.env.prd
|
.env.prd
|
||||||
|
.env.development
|
||||||
|
.env.*
|
||||||
|
|
||||||
/media
|
/media
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
30
src/components/admin/shims/ShimContentField.tsx
Normal file
30
src/components/admin/shims/ShimContentField.tsx
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
164
src/components/frontend/intelligence/CreateCampaignDialog.tsx
Normal file
164
src/components/frontend/intelligence/CreateCampaignDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/frontend/intelligence/create.ts
Normal file
72
src/components/frontend/intelligence/create.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/components/frontend/shims/ShimLoader.tsx
Normal file
56
src/components/frontend/shims/ShimLoader.tsx
Normal 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
153
src/lib/shims/evaluate.ts
Normal 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;
|
||||||
|
}
|
||||||
8535
src/migrations/20251206_065831.json
Normal file
8535
src/migrations/20251206_065831.json
Normal file
File diff suppressed because it is too large
Load diff
8535
src/migrations/20251212_183607.json
Normal file
8535
src/migrations/20251212_183607.json
Normal file
File diff suppressed because it is too large
Load diff
8863
src/migrations/20251219_061127.json
Normal file
8863
src/migrations/20251219_061127.json
Normal file
File diff suppressed because it is too large
Load diff
8995
src/migrations/20251222_200320.json
Normal file
8995
src/migrations/20251222_200320.json
Normal file
File diff suppressed because it is too large
Load diff
9106
src/migrations/20251223_055859.json
Normal file
9106
src/migrations/20251223_055859.json
Normal file
File diff suppressed because it is too large
Load diff
9237
src/migrations/20260109_065519.json
Normal file
9237
src/migrations/20260109_065519.json
Normal file
File diff suppressed because it is too large
Load diff
15515
src/migrations/20260816_213455.json
Normal file
15515
src/migrations/20260816_213455.json
Normal file
File diff suppressed because it is too large
Load diff
15515
src/migrations/20260816_220000.json
Normal file
15515
src/migrations/20260816_220000.json
Normal file
File diff suppressed because it is too large
Load diff
15671
src/migrations/20260817_053752.json
Normal file
15671
src/migrations/20260817_053752.json
Normal file
File diff suppressed because it is too large
Load diff
15972
src/migrations/20260818_024510.json
Normal file
15972
src/migrations/20260818_024510.json
Normal file
File diff suppressed because it is too large
Load diff
18648
src/migrations/20260825_184347.json
Normal file
18648
src/migrations/20260825_184347.json
Normal file
File diff suppressed because it is too large
Load diff
175
src/migrations/20260825_220000.ts
Normal file
175
src/migrations/20260825_220000.ts
Normal 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";`);
|
||||||
|
}
|
||||||
19001
src/migrations/20260826_043422.json
Normal file
19001
src/migrations/20260826_043422.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";
|
||||||
|
`);
|
||||||
|
}
|
||||||
16667
src/migrations/_archived/20260820_004157.json
Normal file
16667
src/migrations/_archived/20260820_004157.json
Normal file
File diff suppressed because it is too large
Load diff
276
src/migrations/_archived/20260820_004157.ts
Normal file
276
src/migrations/_archived/20260820_004157.ts
Normal 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";
|
||||||
|
`)
|
||||||
|
}
|
||||||
17726
src/migrations/_archived/20260821_043007.json
Normal file
17726
src/migrations/_archived/20260821_043007.json
Normal file
File diff suppressed because it is too large
Load diff
18648
src/migrations/_archived/20260825_184347.json
Normal file
18648
src/migrations/_archived/20260825_184347.json
Normal file
File diff suppressed because it is too large
Load diff
510
src/migrations/_archived/20260825_184347.ts
Normal file
510
src/migrations/_archived/20260825_184347.ts
Normal 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";`)
|
||||||
|
}
|
||||||
19001
src/migrations/_staged/20260825_201135.json
Normal file
19001
src/migrations/_staged/20260825_201135.json
Normal file
File diff suppressed because it is too large
Load diff
2314
src/migrations/_staged/20260825_201135.ts
Normal file
2314
src/migrations/_staged/20260825_201135.ts
Normal file
File diff suppressed because it is too large
Load diff
19001
src/migrations/_staged/20260825_201536.json
Normal file
19001
src/migrations/_staged/20260825_201536.json
Normal file
File diff suppressed because it is too large
Load diff
2314
src/migrations/_staged/20260825_201536.ts
Normal file
2314
src/migrations/_staged/20260825_201536.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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'
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue